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:
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.”
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 storedlet 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 upxhr.setRequestHeader('X-File-Id', fileId);// Where we're resuming from, so the server knows to append, not overwritexhr.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 sourcexhr.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.
const text = 'RESUMABLE-UPLOAD-DEMO-PAYLOAD';
const blob = new Blob([text]);
const full = document.getElementById('bs-full');
const tail = document.getElementById('bs-tail');
const range = document.getElementById('bs-range');
const num = document.getElementById('bs-num');
full.textContent = text + ' (' + blob.size + ' bytes)';
range.max = String(blob.size);
async function render() {
const start = Number(range.value);
num.textContent = start;
const piece = blob.slice(start);
const sent = await piece.text();
tail.textContent = sent === '' ? '(nothing left to send)' : sent;
}
range.addEventListener('input', render);
render();
On its side, the server checks its records. If it has a partial upload for that fileId whose stored size is exactlyX-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.
const TOTAL = 1000; // KB
const CHUNK = 90; // KB per tick
let stored = 0; // bytes the "server" has committed
let sent = 0; // bytes the browser has pushed this attempt
let timer = null;
const sentBar = document.getElementById('ru-sent');
const storeBar = document.getElementById('ru-store');
const status = document.getElementById('ru-status');
const upBtn = document.getElementById('ru-up');
const dropBtn = document.getElementById('ru-drop');
const resetBtn = document.getElementById('ru-reset');
const log = document.getElementById('ru-log');
function pct(n) { return (n / TOTAL * 100) + '%'; }
function render() {
sentBar.style.width = pct(sent);
storeBar.style.width = pct(stored);
}
function say(msg) { log.textContent += msg + '\n'; }
function stop() {
clearInterval(timer);
timer = null;
upBtn.disabled = false;
dropBtn.disabled = true;
}
function tick() {
stored = sent; // server acks the in-flight chunk
if (stored >= TOTAL) {
render();
say('Server stored all ' + TOTAL + ' KB. Upload complete.');
status.textContent = 'Done.';
stop();
upBtn.disabled = true;
return;
}
sent = Math.min(sent + CHUNK, TOTAL);
status.textContent = 'Uploading. sent=' + sent + ' KB, stored=' + stored + ' KB.';
render();
}
upBtn.addEventListener('click', () => {
say('GET /status -> startByte = ' + stored + ' KB');
sent = stored; // trust the server, not the old sent count
render();
upBtn.disabled = true;
dropBtn.disabled = false;
timer = setInterval(tick, 220);
});
dropBtn.addEventListener('click', () => {
const lost = sent - stored;
stop();
say('Connection dropped. ' + lost + ' KB were "sent" but never stored.');
status.textContent = 'Dropped at stored=' + stored + ' KB. Press Upload to resume.';
});
resetBtn.addEventListener('click', () => {
stop();
stored = 0; sent = 0;
log.textContent = '';
status.textContent = 'Ready. File is 1000 KB.';
upBtn.disabled = false;
render();
});
render();
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.