Works with any backend npm install Zero dependencies

How chunked uploads work

A chunked upload is a small idea with large consequences: instead of one request carrying a whole file, many requests each carry a slice. Everything good about it — resume, retry, parallelism, progress that means something — follows from that.

Slicing a file costs nothing

A File is a Blob, and Blob.slice() returns a view over the same underlying data without copying it or reading it into memory. That is what makes chunking practical in a browser: you can address a 4 GB file in 5 MB pieces without ever holding 4 GB anywhere.

const CHUNK = 5 * 1024 * 1024;
const total = Math.ceil(file.size / CHUNK);

for (let i = 0; i < total; i++) {
    const slice = file.slice(i * CHUNK, Math.min((i + 1) * CHUNK, file.size));
    await sendChunk(slice, i, total);
}

That loop is the whole concept. The rest of this page is the details that decide whether it works in production.

What goes on the wire

Each chunk request must carry enough for the server to place it: which upload it belongs to, which index it is, how many there are in total, and the original file name. There are two common encodings, and it is worth knowing which your server expects:

  • Raw body plus headers — the chunk bytes are the request body and the metadata rides in headers (X-Upload-Id, X-Chunk-Index, X-Chunk-Count, X-File-Name). Efficient, no encoding overhead. This is what MultipleUpload sends.
  • Multipart form — metadata as form fields alongside the chunk as a file part. Slightly larger, but trivial to parse with an existing form parser.

Mismatched expectations here are a classic integration failure: the client streams a raw body, the server tries to parse multipart, and every chunk fails in a way the error message does not explain. If chunks 404 or 500 while single uploads work, check this first.

The server side, in three rules

  1. Write each chunk to its own file, named by index, in a directory named by the upload id. Appending to one file forces strict ordering and breaks parallel uploads.
  2. Assemble only when every index is present. Counting files is not the same as having them: a chunk that is still being written exists on disk but is incomplete. Write to a temporary name and rename on completion, and check for indices 0..n-1 rather than a count.
  3. Validate on every chunk, not just the first, and again at completion. Anything else can be bypassed by simply not sending the chunk that carries the check.

Assembly is not free. Reassembling reads and writes the entire file a second time. On a busy server that doubling is often the real cost of chunking — one reason direct-to-cloud uploads, where the storage service assembles the parts, scale better.

Retry and resume are different features

Retry handles a chunk that failed while the page is still open: the client simply sends that slice again. Because a chunk is small, this is cheap, and it is why chunked uploads survive flaky networks that would kill a single large request.

Resume handles the page being closed or reloaded. That needs two extra things: the client must persist enough state to reconstruct the upload (the upload id, the chunk size, which indices completed — and, if you want resume without re-selecting the file, the file blob itself in IndexedDB), and it must verify that state against the server before trusting it. Temp directories get swept, servers get redeployed; a client that assumes its record of "chunks 0-40 uploaded" is still true can produce a file with holes in it.

// Ask the server what it actually holds before resuming
const res  = await fetch(`/api/upload/chunk/status?id=${uploadId}`);
const have = (await res.json()).received;    // e.g. [0,1,2,5,6]
const missing = allIndexes.filter(i => !have.includes(i));

Parallelism, and its limits

Chunks are independent, so several can be in flight at once — which is usually where the speed comes from, not just the reliability. Browsers cap connections per host (about six on HTTP/1.1), so three or four concurrent chunks captures most of the benefit; past that you add contention without adding throughput. On a congested uplink, more parallelism can be actively slower.

Where tus fits

tus is an open protocol that standardizes exactly this problem: a POST creates an upload and returns a URL, PATCH requests append at a byte offset, and a HEAD asks the server how far it got. Because the offset is authoritative and server-held, resume needs no client bookkeeping at all — the client asks where to continue from.

Use tus when you want interoperability with existing servers (tusd and others) or when uploads must survive across devices and sessions. Use plain chunking when you control both ends and want the simplest possible server. MultipleUpload speaks both.

A checklist for shipping it

  • Chunk size chosen for your users' networks (1–5 MB is a sane default; S3 multipart needs ≥5 MB)
  • Concurrency of 3–4, or adaptive
  • Per-chunk retry with exponential backoff
  • Server validates every chunk and the completion call
  • Chunks written to temp names, renamed on completion; assembly requires all indices
  • Abandoned upload directories swept on a timer
  • A stall timeout, so a silently dead connection is retried rather than waited on forever
  • Resume verified against server state, not trusted from local storage