Building a Resumable Upload Flow with tus

The tus protocol turns a fragile single POST into a creation request plus offset-tracked PATCH chunks, so an interrupted transfer resumes from the last acknowledged byte instead of restarting.

Resumable transfers are the core promise of resumable upload state machines within the broader frontend UX, chunking and progress tracking discipline. The tus open protocol standardizes that promise over plain HTTP: a POST creates an upload resource, each PATCH carries an Upload-Offset header, and a HEAD request lets the client rediscover the server’s offset after any interruption. The tus-js-client library implements this handshake and persists enough metadata in the browser to resume after a full reload.

When to use this approach

  • You upload large media files (hundreds of MB to multi-GB) over unreliable mobile or flaky Wi-Fi connections where a single broken socket should not waste the whole transfer.
  • You want a standardized, server-agnostic resume mechanism rather than a bespoke chunking scheme glued to one backend.
  • Your storage tier (tusd, a Node tus server, or an S3-backed tus endpoint) speaks the tus protocol, often paired with S3 presigned URL workflows for the final object commit.

Prerequisites

  1. Node 20+ and a bundler (Vite, esbuild, or webpack) for ESM.
  2. npm install tus-js-client (v4+).
  3. A reachable tus 1.0.0 endpoint that returns Tus-Resumable: 1.0.0 and supports the creation extension.
  4. CORS on the endpoint exposing Location, Upload-Offset, and Tus-Resumable response headers.

Implementation

The class below wires tus-js-client to a file input, persists the upload URL through localStorage via the library’s URL storage, reports progress, and resumes automatically when the page is reloaded mid-transfer.

import * as tus from "tus-js-client";

export interface ResumableHandlers {
  onProgress: (sent: number, total: number) => void;
  onSuccess: (uploadUrl: string) => void;
  onError: (err: Error) => void;
}

export class TusUploader {
  private upload: tus.Upload | null = null;

  constructor(
    private readonly endpoint: string,
    private readonly handlers: ResumableHandlers,
  ) {}

  async start(file: File): Promise<void> {
    // Look for an interrupted upload of the SAME file before creating a new one.
    const previous = await this.findPrevious(file);

    this.upload = new tus.Upload(file, {
      endpoint: this.endpoint,
      chunkSize: 8 * 1024 * 1024, // 8 MB PATCH bodies
      retryDelays: [0, 1000, 3000, 5000, 10000],
      removeFingerprintOnSuccess: true,
      metadata: {
        filename: file.name,
        filetype: file.type || "application/octet-stream",
      },
      onProgress: (sent, total) => this.handlers.onProgress(sent, total),
      onSuccess: () => {
        const url = this.upload?.url ?? "";
        this.handlers.onSuccess(url);
      },
      onError: (err) => this.handlers.onError(err as Error),
    });

    if (previous.length > 0) {
      // Resume from the most recent stored URL instead of POSTing again.
      this.upload.resumeFromPreviousUpload(previous[0]);
    }

    this.upload.start();
  }

  pause(): void {
    void this.upload?.abort(); // keeps the fingerprint so start() can resume
  }

  private async findPrevious(file: File): Promise<tus.PreviousUpload[]> {
    if (!tus.isSupported) return [];
    const found = await tus.Upload.findPreviousUploads(
      new tus.Upload(file, { endpoint: this.endpoint }),
    );
    return found;
  }
}

// --- Wire to the DOM ---
const input = document.querySelector<HTMLInputElement>("#file")!;
const bar = document.querySelector<HTMLProgressElement>("#bar")!;

input.addEventListener("change", () => {
  const file = input.files?.[0];
  if (!file) return;

  const uploader = new TusUploader("https://uploads.example.com/files/", {
    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);
});

Line-by-line of the critical parameters

  • endpoint is the creation URL. The first POST here returns 201 Created with a Location header; tus-js-client PATCHes to that returned URL for the rest of the transfer.
  • chunkSize caps each PATCH body. Without it the client streams the whole file in one request, which defeats resume on servers that buffer the full body. An 8 MB chunk balances request overhead against retry cost.
  • retryDelays drives automatic retry on transient network errors. Each entry is a millisecond delay; an empty array disables retries. The same backoff philosophy is detailed in implementing exponential backoff for failed chunks.
  • metadata is sent as the base64-encoded Upload-Metadata header on creation, so the server can name the final object.
  • resumeFromPreviousUpload skips the creation POST and issues a HEAD to read the server’s Upload-Offset, then continues PATCHing from there.
  • findPreviousUploads reads the URL storage (localStorage by default) keyed by a fingerprint of the file (name + size + last-modified). After a reload, the fingerprint still matches, so the prior upload URL is recovered.
  • abort() (in pause) stops the in-flight request but leaves the fingerprint intact, so the next start() resumes rather than restarts.

The sequence below shows the three request types and where resume re-enters the flow.

tus creation, PATCH, and resume sequence Client POSTs to create an upload, PATCHes chunks with Upload-Offset, then after a reload sends HEAD to read the offset and resumes PATCHing. Browser tus server POST (create) → 201 Location PATCH Upload-Offset: 0 PATCH Upload-Offset: 8388608 page reload — socket lost HEAD → Upload-Offset: 8388608 PATCH resumes at byte 8388608
How tus creates, streams, and resumes an upload across a page reload.

Configuration gotchas

Error: tus: unexpected response while creating upload, originated from request (response code: 404 ...) — the endpoint is wrong or missing its trailing slash, so the creation POST never reaches the tus handler. Confirm the URL returns Tus-Resumable: 1.0.0 to an OPTIONS request.

Resume restarts from zero after reload. The fingerprint includes lastModified. If your app re-creates the File object (for example by re-reading from a different source) the fingerprint changes and no previous upload is found. Pin the same File reference, or store the upload URL yourself and pass it via resumeFromPreviousUpload.

Upload-Offset header missing / CORS-stripped headers. Browsers hide response headers from JS unless Access-Control-Expose-Headers lists Location, Upload-Offset, Tus-Resumable. Without exposure, tus-js-client cannot read the offset and aborts. Add the header on the server’s CORS policy.

413 on the first PATCH. A reverse proxy client_max_body_size smaller than chunkSize rejects the body. Lower chunkSize or raise the proxy limit; the two must agree.

Verification

Drive the client and confirm a resume actually skips bytes by inspecting the offset returned by HEAD:

# 1. Create an upload, capture the Location URL
LOCATION=$(curl -sS -i -X POST https://uploads.example.com/files/ \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Length: 104857600" \
  -H "Upload-Metadata: filename dmlkZW8ubXA0" \
  | grep -i '^location:' | tr -d '\r' | awk '{print $2}')

# 2. Send the first 8 MB chunk at offset 0
head -c 8388608 video.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 8388608 — proof the resume point advanced
curl -sS -I "$LOCATION" -H "Tus-Resumable: 1.0.0" | grep -i upload-offset
# Expected: Upload-Offset: 8388608

FAQ

Does tus-js-client work without any server library?

No — the server must implement the tus 1.0.0 protocol (creation + core PATCH/HEAD). tusd, the official Node @tus/server, and several S3-backed gateways do. The client only speaks the protocol; it cannot resume against a plain multipart endpoint.

Where is the resume state stored, and does it survive a reload?

By default in localStorage under a fingerprint of the file. It survives a full page reload and browser restart. It does not survive clearing site data or selecting a different File, because the fingerprint will no longer match.

Can I run several tus uploads in parallel?

Yes. Each tus.Upload instance manages its own URL and offset independently. Throttle concurrency to 2–3 to avoid saturating the connection and triggering proxy timeouts, which you can diagnose alongside browser timeout and retry logic.

How is this different from storing offsets in IndexedDB myself?

tus standardizes the offset negotiation over HTTP. If you need richer client-side state — paused queues, file handles, multiple chunk records — pair it with persisting upload state in IndexedDB.