A 4 GB video upload reaches 97%.
Then the user's Wi-Fi drops for five seconds.
If your upload implementation starts again from byte zero, you do not really have a large-file upload system. You have a very optimistic file transfer.
Reliable browser uploads need to assume that connections fail, tabs reload, requests time out, and clients occasionally send the same request twice.
The usual solution is chunked, resumable uploads. But splitting a file into pieces is only the beginning. The harder part is making retries safe and keeping client and server state synchronized.
Start with an upload session
Instead of sending the entire file directly, the client first asks the backend to create an upload session.
POST /uploads
Content-Type: application/json
{
"filename": "project-final.mp4",
"size": 4294967296,
"contentType": "video/mp4"
}
The server returns an identifier and the chunk size it expects:
{
"uploadId": "upl_7f91c2",
"chunkSize": 8388608
}
Using a server-defined chunk size is useful because the backend can tune it without requiring frontend changes.
For example, 8 MB chunks provide a reasonable balance between request overhead and retry cost.
Slice the file in the browser
The browser does not need to load the entire file into memory.
File inherits from Blob, which means we can use slice():
const CHUNK_SIZE = 8 * 1024 * 1024;
function getChunk(file, index) {
const start = index * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
return file.slice(start, end);
}
Then upload one chunk at a time:
async function uploadChunk(uploadId, index, chunk) {
const response = await fetch(
`/uploads/${uploadId}/chunks/${index}`,
{
method: "PUT",
body: chunk
}
);
if (!response.ok) {
throw new Error(`Chunk ${index} failed`);
}
}
Sequential uploads are simple and predictable. Once that works reliably, limited concurrency can improve throughput.
Do not jump immediately to uploading 20 chunks in parallel. On slower connections that can make performance worse rather than better.
Retries must be idempotent
The most important backend property is this:
Uploading chunk 17 twice should produce the same result as uploading it once.
Imagine the server successfully stores a chunk, but the response is lost before reaching the browser.
The client sees a timeout.
It has no idea whether the upload succeeded.
So it retries.
If your backend treats that retry as a completely new operation, corruption or duplicate data becomes possible.
A chunk endpoint should therefore behave idempotently:
PUT /uploads/upl_7f91c2/chunks/17
The identity of the operation is already encoded in the URL.
The backend can safely replace or confirm the existing chunk rather than append arbitrary bytes to a growing file.
This is much safer than a design where every request simply means "append these bytes."
Track completed chunks on the server
The browser should never be the only source of truth.
Store upload state such as:
{
"uploadId": "upl_7f91c2",
"status": "uploading",
"totalChunks": 512,
"completedChunks": [0, 1, 2, 3, 4, 6, 7]
}
Now a client can reload the page and ask:
GET /uploads/upl_7f91c2
The response tells it which chunks are already present.
const state = await fetch(
`/uploads/${uploadId}`
).then(r => r.json());
const completed = new Set(state.completedChunks);
for (let i = 0; i < totalChunks; i++) {
if (completed.has(i)) continue;
await uploadChunk(
uploadId,
i,
getChunk(file, i)
);
}
There is one limitation here: after a full browser reload, the browser cannot silently regain access to the original local file.
The user may need to select it again.
You should therefore verify that the reselected file matches the original session before continuing.
Useful checks include filename, file size, modification timestamp, or a stronger fingerprint.
Never resume against a different file just because its name happens to match.
Progress should represent confirmed data
A misleading progress bar is worse than no progress bar.
Do not report progress merely because the browser has attempted to send chunks.
Track bytes that the server has confirmed.
function calculateProgress(
completedChunks,
chunkSize,
fileSize
) {
const uploaded = Math.min(
completedChunks * chunkSize,
fileSize
);
return Math.round(
(uploaded / fileSize) * 100
);
}
If you upload chunks concurrently, count each chunk only after its request succeeds.
That way a retry does not make the progress bar jump backward or accidentally exceed 100%.
Add bounded retry behavior
Networks fail. Retrying immediately in a tight loop is not a recovery strategy.
Use exponential backoff:
const delay = ms =>
new Promise(resolve => setTimeout(resolve, ms));
async function withRetry(fn, maxAttempts = 5) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxAttempts) {
throw error;
}
const wait =
Math.min(1000 * 2 ** (attempt - 1), 10000);
await delay(wait);
}
}
}
Then:
await withRetry(() =>
uploadChunk(uploadId, index, chunk)
);
In production, adding random jitter is also useful so thousands of clients do not all retry at exactly the same moment after a temporary outage.
Do not trust chunk metadata from the client
A client may claim:
chunkIndex = 42
That does not mean the server should blindly accept it.
Validate:
- the upload session exists
- the session belongs to the authenticated user
- the upload is still open
- the chunk index is within range
- the body is not larger than the configured chunk size
- the declared total file size is within account limits
If your system uses object storage, upload permissions should also be scoped to one specific upload session rather than granting broad write access to a bucket.
Finalization should be explicit
Uploading the final chunk should not automatically mean the file is ready.
Use a separate completion step:
POST /uploads/upl_7f91c2/complete
The backend can then verify that every required chunk exists.
Only after validation should the upload move into a state such as:
uploading
→ assembling
→ processing
→ ready
For media systems, "uploaded" and "ready" are often very different states.
A video may still require metadata extraction, thumbnail generation, transcoding, or validation before users should see it.
Clean up abandoned sessions
Resumable uploads create temporary state.
Some users will never finish.
Without cleanup, incomplete chunks eventually become an expensive pile of orphaned storage.
Store an expiration time with every upload session and periodically delete sessions that have not been touched for a reasonable period.
Do not make the timeout too aggressive. A multi-gigabyte upload on a slow connection can legitimately take hours.
The architecture matters more than the progress bar
Large-file uploading looks like a frontend feature, but most reliability problems are architectural.
The browser should be able to retry.
The server should make retries harmless.
Upload progress should come from confirmed state.
Finalization should validate completeness.
And temporary data should have an explicit lifecycle.
Once those pieces are in place, a failed request becomes boring.
That is exactly what you want.
The user reconnects, the client checks what already arrived, and the upload continues instead of throwing away gigabytes of completed work.
This article was created with AI assistance and reviewed for technical accuracy before publication.
Top comments (0)