Resumable Uploads Explained: Chunked File Uploads in Vanilla JS
A normal file upload is one big request. If the connection drops at 90%, the user starts again from 0%. On a phone connection, a 200 MB video can become nearly impossible to send.
Chunked uploading fixes this with a simple idea: cut the file into pieces, send the pieces one by one, and let the server put them back together. If the connection dies, you only resend the pieces the server never got.
The moving parts
- The browser slices the file with
Blob.slice()— no plugin, no library needed. - Each chunk is sent with three pieces of information: an upload ID, the chunk index, and the total chunk count.
- The server stores the chunks under that upload ID.
- When the last chunk arrives, the server joins them in order into the final file.
- To resume, the client first asks the server "which chunks do you already have for this upload ID?" and sends only the missing ones.
A minimal client sketch
const CHUNK = 2 * 1024 * 1024; // 2 MB
async function upload(file, uploadId) {
const total = Math.ceil(file.size / CHUNK);
const have = await fetch(`/upload.php?action=status&id=${uploadId}`)
.then(r => r.json()); // { received: [0, 1, 2, ...] }
for (let i = 0; i < total; i++) {
if (have.received.includes(i)) continue; // already there, skip
const body = new FormData();
body.append('chunk', file.slice(i * CHUNK, (i + 1) * CHUNK));
body.append('index', i);
body.append('total', total);
body.append('id', uploadId);
await fetch('/upload.php', { method: 'POST', body });
}
}
A minimal server sketch (PHP)
<?php
$id = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['id'] ?? '');
$index = (int)($_POST['index'] ?? -1);
$total = (int)($_POST['total'] ?? 0);
$dir = __DIR__ . '/chunks/' . $id;
if (!is_dir($dir)) mkdir($dir, 0777, true);
move_uploaded_file($_FILES['chunk']['tmp_name'], "$dir/$index.part");
if (count(glob("$dir/*.part")) === $total) {
$out = fopen(__DIR__ . '/files/' . $id . '.bin', 'wb');
for ($i = 0; $i < $total; $i++) {
fwrite($out, file_get_contents("$dir/$i.part"));
unlink("$dir/$i.part");
}
fclose($out);
}
That is the whole concept. Everything else is details.
The details that bite in production
- Validate on the server, always. Check the assembled file's real type and size server-side. Never trust the file name or type the browser claims.
- Clean up abandoned uploads. People close tabs mid-upload. A cron job that deletes chunk folders older than a day keeps the disk sane.
- Chunk size is a trade-off. Too small (under 512 KB) and request overhead dominates. Too big (over 10 MB) and each failure wastes real data. 2 to 5 MB is the comfortable middle.
- Cap the total size and say the limit in the UI before the user starts, not after they fail.
- Progress is per chunk. Completed chunks divided by total chunks is an honest progress bar, and it survives a page refresh if you keep the upload ID.
Full disclosure, and the shortcut
I turned this into a small commercial uploader (vanilla JS on the front, a PHP endpoint at the back, pause/resume, drag and drop, validation) because I kept rebuilding the same thing. Live demo:
The finished version is here (my product, and this is my referral link):
If you only upload small files on good connections, the sketch above is honestly all you need.
Hit a chunking edge case I did not cover? Ask in the comments.
Top comments (0)