Resumable file upload

Uploading a file with fetch takes only a few lines. Uploading a large file over a shaky connection is where things get interesting. The Wi-Fi drops at 90%, and now what? Start over from zero, or pick up where you left off?

The browser has no built-in “resume” button for uploads. But every piece you need to build one is already there. This article walks through the design and shows why the naive approach fails.

The core problem: who knows what the server received?

To resume an upload, you need one number: how many bytes did the server actually store before the connection died? Send from that byte, skip everything before it, done.

The trouble is figuring out that number reliably. There’s an obvious-looking candidate, and it’s a trap.

The progress event tells you what you sent, not what arrived

XMLHttpRequest fires xhr.upload.onprogress as data leaves the browser. It looks like exactly what you want:

xhr.upload.onprogress = (event) => {
  // event.loaded = bytes sent so far, event.total = total to send
  console.log(`Sent ${event.loaded} of ${event.total} bytes`);
};

Here’s the catch. This event fires when bytes leave your machine. It says nothing about whether those bytes reached the server and were written to disk. The browser has no way to know that.

Browser
onprogress fires here
Proxy
may buffer
Network
may drop
Server
only this counts
A byte can be 'sent' by the browser yet never reach the server's storage.

Any hop in between can swallow bytes. A local network proxy might buffer them and never flush. The server process might crash mid-write. The connection might snap in the middle. In all of these, onprogress already counted those bytes as “loaded.”

So the progress event is fine for one job: drawing a smooth progress bar for the user. It is useless as a source of truth for resuming.

The real answer: ask the server. Before resuming, make a small request that says “how many bytes of this file do you have?” The server checks its records and replies with an exact number.

The algorithm

Three steps: identify the file, ask the server for its byte count, then send the remaining slice.

1. Build a stable fileId from name + size + lastModified

2. GET /status with header X-File-Id
← server replies with startByte (0 if new)

3. POST /upload with file.slice(startByte)
headers: X-File-Id, X-Start-Byte

Resume flow: identify, query status, upload the remaining slice.

Step 1 — Give the file a stable id

The server needs to recognize the file across separate requests, even after a page reload. You build an id from properties that stay constant for a given file:

let fileId = file.name + '-' + file.size + '-' + file.lastModified;

Why these three? They’re what a File object exposes for free, and together they’re specific enough that two different files almost never collide. If the user picks a different file — different name, different size, or a newer modification time — the id changes, and the server treats it as a fresh upload rather than resuming the wrong data.

Change any one of the three fields below and watch the id shift. In the real API these values come straight off the File object; here you can edit them by hand to see when the server would treat the upload as “the same file” versus “start over.”

interactiveBuild a stable fileId

Step 2 — Ask the server how much it already has

Send a request carrying the id, and read back the byte offset to resume from:

let response = await fetch('status', {
  headers: {
    'X-File-Id': fileId
  }
});

// The server tells us how many bytes it already stored
let startByte = +await response.text();

The leading + converts the text response (a string like "1048576") into a number. This is the contract you’re agreeing to with the server: it tracks uploads keyed by the X-File-Id header, and for a brand-new file it answers 0. All of that logic lives on the server — the browser just trusts the number it gets back.

Step 3 — Send only the missing part

Now you upload, but starting from startByte. A File is a Blob, and Blob.slice(start) returns a new blob containing just the bytes from start onward — without copying the whole file into memory.

xhr.open("POST", "upload");

// Which file this is, so the server can match it up
xhr.setRequestHeader('X-File-Id', fileId);

// Where we're resuming from, so the server knows to append, not overwrite
xhr.setRequestHeader('X-Start-Byte', startByte);

xhr.upload.onprogress = (e) => {
  // Offset by startByte so the bar reflects the whole file, not just this chunk
  console.log(`Uploaded ${startByte + e.loaded} of ${startByte + e.total}`);
};

// file can come from input.files[0] or any other source
xhr.send(file.slice(startByte));

Two headers do the coordinating. X-File-Id tells the server which file this is. X-Start-Byte tells it this is a resume — you’re handing over the tail end, not the whole thing.

bytes 0 … startByte
already on server
bytes startByte … end
file.slice(startByte) sends this
slice(startByte) sends only the bytes the server is still missing.

Because a File is just a Blob, you can try slice on a real one right here. Drag the start byte and the panel below rebuilds the tail with blob.slice(startByte), reads it back, and shows exactly what would go on the wire.

interactiveslice(startByte) on a real Blob

On its side, the server checks its records. If it has a partial upload for that fileId whose stored size is exactly X-Start-Byte, it appends the incoming data to the existing file. That exact-match check is the safety rail: if the numbers don’t line up, appending would create a hole or an overlap, so the server should refuse rather than corrupt the file.

Putting it together

Because a live demo can’t touch the network here, the panel below stands in a simulated server so you can watch the resume logic itself. Start the upload, then hit Drop connection partway through. The blue bar (bytes the browser thinks it sent) runs ahead of the green bar (bytes the server actually stored). Press Resume: it re-reads the green count and continues from there — the over-optimistic blue bytes are simply thrown away.

interactiveResume after a dropped connection

Step back and look at what you assembled: control over request headers, a real progress indicator, and the ability to send arbitrary byte ranges of a file. That’s the same toolkit a desktop file manager uses. With these primitives you can build resumable uploads — and chunked uploads, parallel uploads, and more on top of the same ideas.