Skip to content

tus resumable uploads

Large uploads — anything over ~100MB — fail all the time. A dropped connection at 90%, a proxy timeout, a memory limit on the server, a browser that gives up after 10 minutes. Plain form data can’t recover from any of these: the whole upload starts over from zero.

There’s also a hard ceiling you can’t configure away: Cloudflare’s proxy caps request bodies at 100MB (200MB on Enterprise). Behind a Cloudflare proxy, a single request over that limit is rejected before it reaches your server — no matter how the server is configured. The only way around it is to never send the whole file in one request.

tus solves both problems. The tus protocol v1 at /uploads breaks a big file into chunks and tracks the progress in SQLite. Each PATCH is a small request — easily under the proxy limit — so a 1GB file uploads as thousands of small, proxy-safe requests. When a connection drops, the client asks the server where it stopped and continues from there — no restart, no lost progress.

If your uploads are small (under ~100MB) and the connection is reliable, use form data instead — it’s simpler and has no protocol overhead. tus is for when big files are the point.

Core protocol + five extensions, all spec-compliant:

  • Core: OPTIONS (capabilities), POST (creation → 201 + Location), HEAD (Upload-Offset/Upload-Length), PATCH (application/offset+octet-stream, 415/409/413 error codes)
  • creation / creation-with-upload: Upload-Metadata (base64 key-value pairs), initial chunk on POST
  • termination: DELETE → 204, file + row removed
  • expiration: Upload-Expires + background sweep
  • checksum: Upload-Checksum (sha1/256/384/512/md5, 460 on mismatch)
  • X-HTTP-Method-Override hook for clients without PATCH/DELETE

Core protocol + five extensions, all spec-compliant:

  • Core: OPTIONS (capabilities), POST (creation → 201 + Location), HEAD (Upload-Offset/Upload-Length), PATCH (application/offset+octet-stream, 415/409/413 error codes)
  • creation / creation-with-upload: Upload-Metadata (base64 key-value pairs), initial chunk on POST
  • termination: DELETE → 204, file + row removed
  • expiration: Upload-Expires + background sweep
  • checksum: Upload-Checksum (sha1/256/384/512/md5, 460 on mismatch)
  • X-HTTP-Method-Override hook for clients without PATCH/DELETE
  • Auth + ownership on every endpoint: the session cookie is required (401) and uploads belong to their creator (404 otherwise).
  • Atomic offset advancement: UPDATE ... WHERE offset = ? — concurrent PATCHes race safely, losers get 409 and re-sync via HEAD.
  • Unguessable ids: 128-bit random base64url ids; files are effectively private by entropy.
  • Stored-XSS guard: /uploads responses carry script-src 'none' in the CSP, and avatars are restricted to raster image types (no SVG).

The client implements the correct resumable pattern:

  1. POST creation with Upload-Length + Upload-Metadata
  2. HEAD to reconcile the offset (resume after interruption)
  3. Chunked PATCH (256KB) with progress
  4. Link the completed upload as the avatar; router.reload() refreshes shared props

Interrupted uploads are remembered in localStorage and resumed when the same file is re-selected.

Variable Default Notes
UPLOAD_DIR ./data/uploads upload bytes on disk
TUS_MAX_SIZE 0 max upload size in bytes (0 = unlimited)
TUS_EXPIRATION_SECONDS 0 unfinished upload TTL (0 = no expiry)