DEV Community

Cover image for Building Resumable Browser Uploads That Survive Real-World Failures
GalleryDock
GalleryDock

Posted on

Building Resumable Browser Uploads That Survive Real-World Failures

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"
}
Enter fullscreen mode Exit fullscreen mode

The server returns an identifier and the chunk size it expects:

{
  "uploadId": "upl_7f91c2",
  "chunkSize": 8388608
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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`);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
}
Enter fullscreen mode Exit fullscreen mode

Now a client can reload the page and ask:

GET /uploads/upl_7f91c2
Enter fullscreen mode Exit fullscreen mode

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)
  );
}
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Then:

await withRetry(() =>
  uploadChunk(uploadId, index, chunk)
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)