Building a Resumable Upload Flow with tus
tus replaces one fragile POST with a creation request plus offset-tracked PATCH chunks, so an interrupted transfer continues from the last byte the server acknowledged instead of starting over.
The tus 1.0.0 protocol is a thin convention over ordinary HTTP: a POST creates an upload resource, each PATCH carries an Upload-Offset header describing where its body belongs, and a HEAD lets the client rediscover the server’s offset after any interruption. There is no envelope format, no framing, no websocket — just three verbs and five headers. tus-js-client implements that handshake in the browser and keeps enough state in localStorage to resume after a full page reload. This page wires it into a real uploader: pause and cancel, a retry policy that distinguishes permanent from transient failures, the four gotchas that quietly break resume in production, and a curl script that proves the offset actually advanced. It sits under resumable upload state machines within frontend UX, chunking and progress tracking.
When to use this approach
- Large media over hostile networks. A 2 GB screen recording uploaded from a train will drop its socket several times. Without resume, each drop costs the entire transfer; with tus the cost is one chunk. Below roughly 100 MB the bookkeeping rarely pays for itself — see multipart vs single-PUT for files under 100MB.
- You want a protocol, not a bespoke scheme. tus is implemented by
tusd,@tus/server, and several gateways, so the client is portable across backends. If you would rather own the state model yourself, the alternative is tracking offsets in persisting upload state in IndexedDB. - You can run a tus-speaking endpoint. The browser cannot resume against a plain form handler. If your only server-side surface is a signing endpoint, use S3 multipart behind S3 presigned URL workflows instead — the resume semantics are similar, the wire protocol is not.
Prerequisites
- Node 20+ and a bundler that emits ESM (Vite, esbuild, or webpack 5).
npm install tus-js-client@^4— the v4 API used below (upload.findPreviousUploads()) differs from v2.- A reachable tus 1.0.0 endpoint that answers
OPTIONSwithTus-Resumable: 1.0.0and listscreationinTus-Extension. - CORS on that endpoint with
Access-Control-Expose-Headers: Location, Upload-Offset, Upload-Length, Tus-Resumable, Upload-ExpiresandPATCH,HEAD,DELETEinAccess-Control-Allow-Methods. - Every proxy in front of the endpoint configured to accept a body at least as large as your
chunkSize.
How the offset handshake works
Discovery comes first. An OPTIONS request returns 204 No Content with Tus-Version: 1.0.0, Tus-Extension (a comma-separated list such as creation,creation-with-upload,expiration,termination,concatenation,checksum), and often Tus-Max-Size. That single request tells you whether the features below are available before you commit a byte.
Creation is a POST to the endpoint carrying Upload-Length in bytes and an optional Upload-Metadata header — comma-separated key base64value pairs. The server replies 201 Created with a Location pointing at the new upload resource. Everything afterwards addresses that URL, not the endpoint.
Transfer is a sequence of PATCH requests to the upload URL. Each one sets Content-Type: application/offset+octet-stream and an Upload-Offset naming the absolute byte position of the first byte in the body. The server appends, then answers 204 No Content with the new Upload-Offset. If your offset does not match the server’s, you get 409 Conflict and no bytes are written — the offset is the only synchronisation primitive in the protocol, which is what makes it safe to retry blindly.
Everything beyond those three verbs is an optional extension, and each one is worth knowing before you design around it. creation-with-upload lets the creation POST carry the first chunk in its body, saving a full round trip — worth 200–400 ms on a mobile link. expiration adds an Upload-Expires response header (an HTTP date) after which the server may garbage-collect a half-finished upload, exactly the way expiring incomplete multipart uploads automatically reclaims orphaned S3 parts. checksum accepts Upload-Checksum: sha1 <base64> per chunk and answers 460 Checksum Mismatch when the body was corrupted in transit; if you want end-to-end integrity instead, hash the whole file first as described in computing file checksums in the browser with Web Crypto. termination adds DELETE. concatenation is what makes parallel part uploads possible.
Implementation
The class below wires tus-js-client to a file input, exposes an explicit phase for your UI, retries only what is worth retrying, and resumes automatically when the same file is picked again after a reload.
import * as tus from "tus-js-client";
export type UploadPhase =
| "idle" | "creating" | "uploading" | "paused" | "done" | "failed";
export interface ResumableHandlers {
onPhase: (phase: UploadPhase) => void;
onProgress: (sent: number, total: number) => void;
onSuccess: (uploadUrl: string) => void;
onError: (err: Error) => void;
}
const CHUNK_SIZE = 8 * 1024 * 1024; // 8 MiB PATCH bodies
export class TusUploader {
private upload: tus.Upload | null = null;
constructor(
private readonly endpoint: string,
private readonly handlers: ResumableHandlers,
) {}
async start(file: File): Promise<void> {
if (!tus.isSupported) {
throw new Error("tus: this browser cannot perform resumable uploads");
}
const upload = new tus.Upload(file, {
endpoint: this.endpoint,
chunkSize: CHUNK_SIZE,
retryDelays: [0, 1000, 3000, 5000, 10000],
storeFingerprintForResuming: true,
removeFingerprintOnSuccess: true,
addRequestId: true,
metadata: {
filename: file.name,
filetype: file.type || "application/octet-stream",
},
// Retry transport faults; give up immediately on anything the server
// will keep rejecting no matter how many times we ask.
onShouldRetry(err, retryAttempt) {
const status = err.originalResponse?.getStatus() ?? 0;
if (status === 400 || status === 403 || status === 404 || status === 413) {
return false;
}
return retryAttempt < 5;
},
onProgress: (sent, total) => this.handlers.onProgress(sent, total),
onSuccess: () => {
this.handlers.onPhase("done");
this.handlers.onSuccess(upload.url ?? "");
},
onError: (err) => {
this.handlers.onPhase("failed");
this.handlers.onError(err);
},
});
// Look for an interrupted upload of the SAME file before creating a new one.
const previous = await upload.findPreviousUploads();
if (previous.length > 0) {
upload.resumeFromPreviousUpload(previous[0]);
this.handlers.onPhase("uploading");
} else {
this.handlers.onPhase("creating");
}
this.upload = upload;
upload.start();
}
/** Stop the in-flight PATCH but keep the stored URL so start() resumes. */
async pause(): Promise<void> {
if (!this.upload) return;
await this.upload.abort(false);
this.handlers.onPhase("paused");
}
/** Stop and ask the server to discard the partial upload (DELETE). */
async cancel(): Promise<void> {
if (!this.upload) return;
await this.upload.abort(true);
this.upload = null;
this.handlers.onPhase("idle");
}
}
// --- Wire to the DOM ---
const input = document.querySelector<HTMLInputElement>("#file");
const bar = document.querySelector<HTMLProgressElement>("#bar");
const phase = document.querySelector<HTMLElement>("#phase");
if (input && bar && phase) {
input.addEventListener("change", () => {
const file = input.files?.[0];
if (!file) return;
const uploader = new TusUploader("https://uploads.example.com/files/", {
onPhase: (next) => { phase.textContent = next; },
onProgress: (sent, total) => { bar.max = total; bar.value = sent; },
onSuccess: (url) => console.log("[tus] stored at", url),
onError: (err) => console.error("[tus] failed:", err.message),
});
void uploader.start(file);
});
}
The sequence below shows the request types in order and where a resume re-enters the flow.
Line-by-line of the critical parameters
endpointis the creation URL and must keep its trailing slash if the server registered the route that way. The firstPOSTreturnsLocation; every later request targets that URL, which you can read fromupload.urlonce it exists.chunkSizecaps eachPATCHbody. Leave it unset andtus-js-clientstreams the whole file in a single request, which defeats resume on any server that buffers the body before writing. It also fixes the granularity of your progress bar, so it feeds directly into showing accurate time-remaining estimates.retryDelaysis the backoff ladder in milliseconds.[0, 1000, 3000, 5000, 10000]buys 19 seconds of tolerance across five attempts beforeonErrorfires. The reasoning behind the shape of that ladder — and why you want jitter once several uploads share a connection — is in implementing exponential backoff for failed chunks.onShouldRetryis the difference between a client that recovers and one that hammers a dead endpoint.err.originalResponseisnullfor pure transport faults (DNS, TLS, socket reset), which is why the status defaults to0and those errors fall through to the attempt counter.metadatais base64-encoded intoUpload-Metadataon creation only. Changing it mid-transfer has no effect; the server named the object when it created the resource.findPreviousUploads()reads the URL storage —localStorageby default — keyed by a fingerprint derived from the file. It returns an array because the same file may have several abandoned uploads; index0is the most recent.resumeFromPreviousUpload()skips creation entirely, issues aHEAD, and continues from whatever offset the server reports. It never trusts the locally cached offset, which is why a server that dropped the upload produces a clean404rather than silent corruption.abort(false)versusabort(true)is pause versus cancel: the first stops the in-flight request and leaves the stored URL alone, the second additionally sendsDELETE(theterminationextension) so the server can free the partial object immediately.
Retry behaviour on a flaky connection
When the radio drops, the in-flight PATCH fails with a ProgressEvent and no status code. tus-js-client waits out the ladder and, on each attempt, re-issues a HEAD before sending data — so if the server did commit part of the failed body before the socket died, the client picks up from the real offset rather than re-sending bytes that already landed. That is the whole reason resume is safe to automate: the client’s belief about progress is refreshed from the server on every recovery.
Two numbers matter when you tune this. The first is total tolerance: sum your retryDelays and ask whether that exceeds a typical outage on your users’ networks — a lift or a tunnel is 20–60 seconds, so a 19-second ladder will surrender in a lift. The second is chunk size, because a failure mid-chunk discards only the unacknowledged part of the current PATCH, so an 8 MiB chunk wastes at most 8 MiB of uplink. When the failure is not transient at all — the disk behind the server filled up, or a proxy refuses the body outright — stop retrying and surface it, which is the argument made in handling 413 and 507 errors during uploads.
Configuration reference
| Option | Type | Default | Effect |
|---|---|---|---|
endpoint |
string |
— | Creation URL; POSTed once per upload |
uploadUrl |
string | null |
null |
Skip creation and PATCH straight to a URL you already hold |
chunkSize |
number |
Infinity |
Bytes per PATCH; must be ≤ every proxy body limit on the path |
retryDelays |
number[] | null |
[0, 1000, 3000, 5000] |
Backoff ladder in ms; null disables retries entirely |
onShouldRetry |
(err, attempt, opts) => boolean |
retries network + 5xx | Per-error veto over the ladder |
parallelUploads |
number |
1 |
Split the file into N partial uploads (needs the concatenation extension) |
uploadDataDuringCreation |
boolean |
false |
creation-with-upload: send the first chunk with the POST, saving a round trip |
storeFingerprintForResuming |
boolean |
true |
Write the upload URL to urlStorage so a reload can find it |
removeFingerprintOnSuccess |
boolean |
false |
Delete the key after onSuccess; leave it false and localStorage accumulates dead entries |
overridePatchMethod |
boolean |
false |
Send POST + X-HTTP-Method-Override: PATCH for proxies that block PATCH |
metadata |
Record<string, string> |
{} |
Base64-encoded into Upload-Metadata at creation |
headers |
Record<string, string> |
{} |
Extra headers on every request — this is where Authorization goes |
addRequestId |
boolean |
false |
Adds X-Request-ID so a failed chunk can be traced in server logs |
Configuration gotchas
The creation POST 404s
tus: unexpected response while creating upload, originated from request (method: POST, url: https://uploads.example.com/files, response code: 404, response text: , request id: n/a) almost always means the endpoint lost or gained a trailing slash relative to the route the server registered. Confirm with curl -sS -i -X OPTIONS https://uploads.example.com/files/ — a correct endpoint answers 204 with Tus-Resumable: 1.0.0, anything else and you are talking to your framework’s 404 handler, not to tus.
Resume restarts from byte zero
The client finds a previous upload by fingerprint, and the browser fingerprint is built from the file’s name, MIME type, size, lastModified timestamp, and the endpoint. Change any component and findPreviousUploads() returns an empty array, so the client cheerfully creates a brand-new upload and re-sends everything.
The practical trap is client-side preprocessing: if you transcode, strip metadata, or re-encode before uploading, the File you hand to tus on the second attempt is a different object with a fresh lastModified. Either do the preprocessing once and cache the result, or bypass the fingerprint entirely by persisting upload.url yourself — read it in onUploadUrlAvailable, store it next to your own job record, and pass it back as uploadUrl on the retry. That is the pattern described in resuming uploads after network loss.
The browser cannot read Upload-Offset
tus: invalid or missing offset value means the response arrived but JavaScript could not see the header. Browsers hide every response header from cross-origin JS unless the server lists it in Access-Control-Expose-Headers; Location and Upload-Offset are the two that break resume when missing. The preflight rules that produce this class of failure are unpacked in fixing CORS preflight errors on S3 uploads.
413 on the first PATCH
Nginx answers 413 Request Entity Too Large when client_max_body_size (default 1 MB) is smaller than chunkSize, and Cloudflare enforces its own per-request body ceiling on lower plans. Because it fails on the very first chunk, this looks like “tus is broken” rather than a sizing problem. Either drop chunkSize under the smallest limit on the path or raise it — see raising Nginx and Cloudflare upload size limits. Note that onShouldRetry above returns false for 413 precisely so the user sees the error in one second instead of nineteen.
The upload URL has expired
Servers implementing the expiration extension delete abandoned uploads, typically after 24 hours. A resume attempt then fails with tus: unexpected response while resuming upload … response code: 404. Treat 404 and 410 on the HEAD as “start over”: drop the stored URL, tell the user their progress was discarded, and create a fresh upload rather than retrying. Silently re-requesting a dead URL is the single most common way a resumable flow turns into an infinite loop.
Verification
Drive the protocol by hand and confirm the offset advances. This proves the server, the CORS policy, and the proxy chain are all correct before any client code is involved.
# 0. Discovery — must print Tus-Resumable and a Tus-Extension list including "creation"
curl -sS -i -X OPTIONS https://uploads.example.com/files/ | grep -i '^tus-'
# 1. Create an upload and capture the Location URL
LOCATION=$(curl -sS -i -X POST https://uploads.example.com/files/ \
-H "Tus-Resumable: 1.0.0" \
-H "Upload-Length: 734003200" \
-H "Upload-Metadata: filename aG9saWRheS5tcDQ=,filetype dmlkZW8vbXA0" \
| grep -i '^location:' | tr -d '\r' | awk '{print $2}')
# 2. Send the first 8 MiB chunk at offset 0 — expect "204 No Content"
head -c 8388608 holiday.mp4 | curl -sS -i -X PATCH "$LOCATION" \
-H "Tus-Resumable: 1.0.0" \
-H "Content-Type: application/offset+octet-stream" \
-H "Upload-Offset: 0" \
--data-binary @-
# 3. HEAD must now report the advanced offset — this is the resume point
curl -sS -I "$LOCATION" -H "Tus-Resumable: 1.0.0" | grep -i upload-offset
# Expected: Upload-Offset: 8388608
# 4. Replaying offset 0 must be REJECTED, not silently accepted
head -c 8388608 holiday.mp4 | curl -sS -o /dev/null -w '%{http_code}\n' \
-X PATCH "$LOCATION" \
-H "Tus-Resumable: 1.0.0" \
-H "Content-Type: application/offset+octet-stream" \
-H "Upload-Offset: 0" \
--data-binary @-
# Expected: 409
Step 4 is the one people skip and the one that matters: a server that accepts a stale offset will corrupt files under retry, and you will only find out when a customer’s video is 8 MiB of duplicated header. In the browser, the equivalent check is to open DevTools, throttle to offline mid-transfer, restore the connection, and confirm the network panel shows exactly one HEAD followed by a PATCH whose Upload-Offset equals the last successful response — not a second POST.
Frequently Asked Questions
Does tus-js-client work without a tus server library?
No. The server must implement tus 1.0.0 core plus the creation extension — tusd, the official @tus/server, and several storage gateways do. The client only speaks the protocol; pointed at a plain multipart handler it will fail on the creation POST because no Location comes back.
Can I upload several parts of one file in parallel?
Yes, with parallelUploads: 4, but only if the server advertises the concatenation extension. The client splits the file into N partial uploads and asks the server to concatenate them at the end. Throughput gains flatten above 4 on most links, and each part consumes its own connection from the browser’s six-per-origin budget, so measure before raising it.
How do I attach an Authorization header?
Pass headers: { Authorization: "Bearer " + token } in the options. It is sent on every request including HEAD and DELETE. If the token can expire mid-upload, refresh it in onBeforeRequest rather than pinning one value at construction time, otherwise a two-hour upload dies on a one-hour token.
Is the chunk boundary the same as a Blob slice?
Effectively, yes — the client reads each chunk with the same slicing mechanics covered in slicing large files with Blob.slice, so only the current chunk is resident in memory. An 8 MiB chunkSize costs about 8 MiB of heap regardless of whether the file is 100 MB or 20 GB.
What happens if the user picks the same file in two tabs?
Both tabs compute the same fingerprint and find the same stored URL, then race each other with conflicting offsets; the loser gets 409 Conflict on every PATCH until it exhausts its retries. Guard with a BroadcastChannel lock or a per-upload record in your own store before calling start().