Skip to content
Runtime

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).

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.

Each in-flight upload is a row in the uploads table, backed by a native object-store multipart upload:

BackendMultipart strategy
Cloudflare R2createMultipartUpload / 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.

All endpoints carry Tus-Resumable: 1.0.0. Supported extensions: creation, creation-with-upload, termination, expiration.

MethodPathPurpose
OPTIONS/api/uploadsDiscovery — returns Tus-Version, Tus-Extension, Tus-Max-Size.
POST/api/uploadsCreate 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:

  • filename or key — the destination storage key (one required).
  • contentType (filetype / type also 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);
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}` },
});
EnvDefaultMeaning
UPLOAD_MAX_BYTES5368709120 (5 GiB)Largest allowed upload (Tus-Max-Size).
UPLOAD_MIN_PART_BYTES5242880 (5 MiB)Minimum non-final part size for object backends.
UPLOAD_TTL_MS86400000 (24 h)Idle lifetime before an unfinished session is swept.
UPLOAD_PART_MAX10000Max parts per upload (S3 limit).

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.)

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.

  • 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.