Resumable uploads
Chunked, resumable file uploads over the TUS 1.0.0 protocol — survive dropped connections, resume from the committed offset, and push files larger than a single request. Works with Uppy / tus-js-client out of the box.
Backlex speaks the TUS 1.0.0 resumable upload protocol at
/api/uploads. Instead of streaming a whole file in one PUT (which a dropped
connection loses, and which can blow the per-request memory/size limits on edge
runtimes), a TUS client splits the file into chunks and PATCHes them one at a
time. If the connection drops, it HEADs the session to learn the committed
offset and continues from there.
Because it is standard TUS, any TUS client works against it directly —
Uppy, tus-js-client,
the Go/Python/Swift/Kotlin clients — just point them at /api/uploads. The
Backlex SDK also ships a thin helper (storage.uploadResumable).
When it’s used
Section titled “When it’s used”The classic single-shot upload (PUT /api/storage/:key) is still there and is
the right tool for small files. Reach for resumable uploads when files are large
or networks are flaky:
- The admin Storage page automatically routes files ≥ 50 MiB through the
resumable path, with chunked progress and resume-on-reload (the session
location is stashed in
localStorage, keyed by the file’s name+size+mtime — re-drop the same file after a reload and it continues). - Your own apps choose per call via the SDK or a TUS client.
How it works
Section titled “How it works”Each in-flight upload is a row in the uploads table, backed by a native
object-store multipart upload:
| Backend | Multipart strategy |
|---|---|
| Cloudflare R2 | createMultipartUpload / resumeMultipartUpload (resumable across requests) |
| S3 (and S3-compatible: MinIO, B2, R2-via-S3, DO Spaces) | native S3 multipart (CreateMultipartUpload → UploadPart → CompleteMultipartUpload) |
| Filesystem (self-host) | offset-append to a temp file, atomically renamed into place on completion |
One TUS PATCH maps to one multipart part. When the committed offset reaches the
declared Upload-Length, the parts are assembled into the final object and a
files row is written — exactly as a normal upload would, so the file is then
served, transformed, signed, and listed like any other.
Object stores require every part except the last to be ≥ 5 MiB, so use a chunk size of 8 MiB (the SDK default) for S3/R2. The filesystem backend has no minimum.
Abandoned sessions are swept automatically: a session that sits unfinished past its TTL (default 24 h) is aborted — the staged multipart upload is discarded and the row removed — inside the same cross-runtime tick that drains the job queue and cron functions. No extra infrastructure.
Protocol surface
Section titled “Protocol surface”All endpoints carry Tus-Resumable: 1.0.0. Supported extensions:
creation, creation-with-upload, termination, expiration.
| Method | Path | Purpose |
|---|---|---|
OPTIONS | /api/uploads | Discovery — returns Tus-Version, Tus-Extension, Tus-Max-Size. |
POST | /api/uploads | Create a session. Upload-Length (required) + Upload-Metadata. Returns 201 + Location. May carry an inline first chunk (creation-with-upload). |
HEAD | /api/uploads/{id} | Returns the committed Upload-Offset (resume point) + Upload-Length. |
PATCH | /api/uploads/{id} | Append a chunk at Upload-Offset (Content-Type: application/offset+octet-stream). Returns 204 + the new Upload-Offset. |
DELETE | /api/uploads/{id} | Terminate a session and discard staged parts. |
Upload-Metadata is the standard comma-separated name base64(value) list.
Backlex reads:
filenameorkey— the destination storage key (one required).contentType(filetype/typealso accepted) — the object MIME type.folderId— target folder, or__root__to opt out of folder auto-derivation from the key path.
Creating a session requires create permission on the files collection (same
gate as PUT /api/storage); PATCH/HEAD/DELETE are tenant-scoped to the
session’s workspace.
Errors follow the protocol: 409 on an Upload-Offset mismatch (another writer
moved the offset — HEAD to resync), 413 over Tus-Max-Size, 415 for a
wrong Content-Type on PATCH, 404 for a missing/terminated session.
import { createClient } from "backlex";const backlex = createClient({ url, apiKey });
// Upload a large File/Blob with progress + auto-resume on transient errors.await backlex.storage.uploadResumable({ key: "videos/keynote.mp4", data: file, // Blob | File | ArrayBuffer | Uint8Array contentType: "video/mp4", chunkSize: 8 * 1024 * 1024, // default onProgress: (sent, total) => console.log(`${Math.round((sent / total) * 100)}%`),});
// Resume a previously-started session (e.g. after a reload) — persist the// returned `location` and hand it back with the same data.await backlex.storage.resumeUpload(location, file);Using Uppy
Section titled “Using Uppy”import Uppy from "@uppy/core";import Tus from "@uppy/tus";
new Uppy().use(Tus, { endpoint: "https://your-app/api/uploads", chunkSize: 8 * 1024 * 1024, headers: { authorization: `Bearer ${apiKey}` },});Configuration
Section titled “Configuration”| Env | Default | Meaning |
|---|---|---|
UPLOAD_MAX_BYTES | 5368709120 (5 GiB) | Largest allowed upload (Tus-Max-Size). |
UPLOAD_MIN_PART_BYTES | 5242880 (5 MiB) | Minimum non-final part size for object backends. |
UPLOAD_TTL_MS | 86400000 (24 h) | Idle lifetime before an unfinished session is swept. |
UPLOAD_PART_MAX | 10000 | Max parts per upload (S3 limit). |
Management
Section titled “Management”In-progress and finished sessions are listable for ops/automation:
GET /api/uploads— list sessions (filter?status=pending|completed|aborted).GET /api/uploads/{id}— inspect one session (size, committed offset, status).- MCP:
uploads.list,uploads.get,uploads.abort. (The byte transfer itself is not exposed over MCP — use the SDK or a TUS client.)
Upload ids are server-issued
Section titled “Upload ids are server-issued”The uploadId a TUS session uses is never taken from the client: it is what the
storage backend returned from createMultipart, stored on the upload row as
storage_upload_id and read back from there on every PATCH. The client
addresses its session by the upload’s own id, not by the backend’s.
That matters because on the fs backend the backend’s id becomes part of a
filename. The S3-compatible endpoint (docs/s3.md) does accept an upload id
from the caller, and validates it; TUS never had to, and this note exists so a
future change does not quietly start.
Limits & follow-ups
Section titled “Limits & follow-ups”- Object backends require ≥ 5 MiB non-final chunks; sub-5 MiB chunk buffering is a planned follow-up. The fs backend accepts any chunk size.
- Storage backend selection is per-deployment (not per-tenant), same as non-resumable uploads — see Storage.