Skip to content
HexaTransfer
Back to blog
Technical Deep Dives

Implement Chunked File Upload in JavaScript

Implement resumable chunked file uploads in JavaScript. Handle large files, track progress, and recover from network interruptions.

Chunked file upload in JavaScript splits a large file into fixed-size pieces (typically 5-10 MB), uploads each as a separate HTTP request, and reassembles them on the server. The pattern solves three real problems: browsers and proxies kill requests over 2 GB, mobile networks drop connections mid-upload, and users want progress feedback. A working implementation uses File.slice() to carve chunks, fetch with an AbortSignal per chunk, server-side assembly via S3 multipart or a custom merger, and a local index in IndexedDB so resumption survives tab reloads.

Why Chunks Beat Single-Shot Uploads

A 4 GB file uploaded as one request fails for predictable reasons: Nginx's default client_max_body_size is 1 MB, Cloudflare caps free-tier uploads at 100 MB per request, AWS API Gateway hard-stops at 10 MB, and mobile Safari kills tabs that hold a 4 GB ArrayBuffer in memory. Chunked uploads dodge every one of those ceilings. You also get progress bars that actually move, retries that don't restart from zero, and the ability to pause and resume. The tradeoff is more server-side state and more round trips — roughly one HTTP request per 5 MB, which on a 10 GB file means 2,000 requests.

Picking a Chunk Size

Chunk size is a throughput versus resilience tradeoff. Too small (under 1 MB) and you spend more time on TLS handshakes than data. Too large (over 100 MB) and a dropped connection wastes minutes of upload. The sweet spot for most networks is 5-10 MB, which matches S3's 5 MB multipart minimum and aligns well with typical TCP window sizes after slow-start.

Measure the user's network first:

const downlink = navigator.connection?.downlink ?? 10;
const chunkSize = downlink > 20 ? 10 * 1024 * 1024 : 5 * 1024 * 1024;

On a 100 Mbit connection, 10 MB chunks finish in about a second each. On 4G, 5 MB chunks give better recovery when you hit a tunnel.

Slicing and Hashing the File

File.slice() returns a Blob that references the same underlying disk bytes without copying, so slicing a 20 GB file costs nothing:

function* sliceFile(file, chunkSize) {
  for (let offset = 0; offset < file.size; offset += chunkSize) {
    yield {
      index: Math.floor(offset / chunkSize),
      blob: file.slice(offset, offset + chunkSize),
      start: offset,
      end: Math.min(offset + chunkSize, file.size)
    };
  }
}

Compute a SHA-256 hash of each chunk before uploading so the server can verify integrity:

const buffer = await chunk.blob.arrayBuffer();
const digest = await crypto.subtle.digest('SHA-256', buffer);
const hash = Array.from(new Uint8Array(digest))
  .map(b => b.toString(16).padStart(2, '0')).join('');

For 10 GB of data, hashing adds maybe 20 seconds on a modern laptop — worth it to catch silent corruption on flaky cellular uplinks.

Uploading With Controlled Concurrency

Sequential uploads waste bandwidth; unlimited parallelism crashes the browser. A concurrency limit of 3-4 in-flight chunks balances both:

async function uploadAll(file, sessionId) {
  const queue = [...sliceFile(file, 5 * 1024 * 1024)];
  const workers = Array.from({ length: 4 }, async () => {
    while (queue.length) {
      const chunk = queue.shift();
      await uploadChunk(chunk, sessionId);
      emitProgress(chunk.index);
    }
  });
  await Promise.all(workers);
}

Each uploadChunk call is a PUT /upload/:sessionId/:index with the blob as the body and the hash in a header. Use AbortController per chunk so you can cancel individual requests without killing the whole batch.

Retrying Without Blowing Up the Server

Network errors need exponential backoff, not tight retry loops. A reasonable policy: 3 attempts, base delay 500 ms, jitter up to 50%:

async function uploadChunk(chunk, sessionId, attempt = 0) {
  try {
    const res = await fetch(`/upload/${sessionId}/${chunk.index}`, {
      method: 'PUT', body: chunk.blob, headers: { 'X-Hash': chunk.hash }
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
  } catch (e) {
    if (attempt >= 3) throw e;
    const delay = 500 * 2 ** attempt + Math.random() * 250;
    await new Promise(r => setTimeout(r, delay));
    return uploadChunk(chunk, sessionId, attempt + 1);
  }
}

Treat 5xx responses as retryable, 4xx as fatal (except 408 and 429). On 429, honor the Retry-After header rather than your local backoff.

Resuming After a Tab Reload

Persist upload state to IndexedDB after every successful chunk:

await db.put('uploads', {
  sessionId, fileName: file.name, fileSize: file.size,
  completedChunks: [...completedSet], updatedAt: Date.now()
}, sessionId);

When the user reopens the page with the same file picker, compare the file's size, lastModified, and name against stored sessions. If there's a match, ask the server which chunks it already received (a simple GET /upload/:sessionId/status returning a bitmap works), then upload only the missing ones. The tus protocol formalizes exactly this pattern with the Upload-Offset header, and the tus-js-client library ships a solid implementation if you don't want to roll your own.

Assembling Chunks on the Server

Two serious options: S3 multipart upload, where each chunk becomes a PartNumber and a final CompleteMultipartUpload glues them, or a custom assembler that writes each chunk to a temp file and concatenates at the end. S3 multipart is cheaper at scale because you never pay for egress during assembly and R2 gives zero-egress reads. The custom approach is simpler to debug and lets you stream-encrypt during assembly.

For S3-style:

const upload = await s3.createMultipartUpload({ Bucket, Key });
// per chunk: s3.uploadPart({ UploadId, PartNumber, Body })
await s3.completeMultipartUpload({ UploadId, MultipartUpload: { Parts } });

Watch out for the 10,000-part limit — for files over 50 GB you need 5 MB+ chunks to stay under it.

Tracking Progress Users Actually Trust

Progress bars that jump around feel broken. Compute progress as bytes uploaded over total bytes, not chunks completed, and smooth it with a moving average over 2 seconds to hide jitter. Use fetch with a ReadableStream and a Transform to count bytes, since XMLHttpRequest.upload.onprogress doesn't always fire reliably on HTTP/3. Display an ETA by dividing remaining bytes by the trailing throughput, but clamp to at least 5 seconds to avoid the infamous "2 seconds remaining... for 10 minutes" experience.

Avoiding Common Footguns

Three mistakes kill chunked uploads in production: forgetting to set Content-Length per chunk (breaks some edge proxies), reusing the same session ID across different files (corrupts assembly), and letting the user change the file mid-upload without versioning the session. Always hash the file's first 1 MB plus its size and lastModified to fingerprint sessions. And never trust the lastModified alone — macOS Finder updates it on metadata changes.

HexaTransfer uses a chunked + resumable pipeline like this under the hood for its 10 GB uploads, with client-side AES-256-GCM added to each chunk before the PUT. Try it at hexatransfer.com — free, no account, 10 GB max.

Pulling It Together

A production-grade chunked uploader is maybe 300 lines of JavaScript: slice with File.slice, hash with SubtleCrypto, upload 3-4 chunks in parallel with exponential backoff, persist session state to IndexedDB, and let the server stitch parts via S3 multipart or a custom merger. Test it against airplane mode toggles, tab reloads, and a 15 GB file on 4G before you trust it. Once that works, adding encryption, progress, and resumability is just extra layers on the same skeleton.

Send large files securely with end-to-end encryption

Transfer files up to 10 GB for free with end-to-end encryption. No account required. Your files are encrypted in your browser before upload — no one else can read them.

Send a file