Slicing Large Files with Blob.slice

Blob.slice(start, end) returns a lightweight view over a byte range of a file without copying or reading it, which is exactly what you need to split a large upload into chunks: compute [start, end) offsets, slice each range lazily, and hand the resulting Blob chunks to your uploader one at a time.

This is part of the File API and Blob objects topic within upload fundamentals and browser APIs. Slicing is the foundation for resumable and parallel uploads.

When to use this approach

  • A file is large enough that a single request risks timeouts, memory pressure, or losing all progress on a network blip.
  • You want resumable uploads where each chunk can be retried independently.
  • You are driving a multipart or chunked protocol that expects fixed-size parts.

Prerequisites

  1. A File/Blob reference from an <input> or a drag-and-drop drop zone.
  2. An endpoint that accepts ranged chunks (a chunked API, or S3 multipart parts).
  3. TypeScript with lib: ["DOM", "ES2022"].

Implementation

slice() is synchronous and cheap β€” it does not read bytes, it just records the offsets. The bytes are only read when you actually consume the chunk (send it, hash it, or call arrayBuffer()). That laziness is what keeps memory flat: you hold one chunk at a time, not the whole file.

export interface ChunkPlan {
  index: number;
  start: number;
  end: number;
  blob: Blob;
}

// Produce lazy chunk descriptors; no bytes are read here.
export function planChunks(file: Blob, chunkSize: number): ChunkPlan[] {
  if (chunkSize <= 0) throw new RangeError("chunkSize must be > 0");
  const plans: ChunkPlan[] = [];
  let index = 0;
  for (let start = 0; start < file.size; start += chunkSize) {
    const end = Math.min(start + chunkSize, file.size);
    plans.push({ index, start, end, blob: file.slice(start, end) });
    index++;
  }
  // A zero-byte file still gets one empty chunk so the upload finalizes.
  if (plans.length === 0) {
    plans.push({ index: 0, start: 0, end: 0, blob: file.slice(0, 0) });
  }
  return plans;
}

// Send chunks sequentially; each carries its byte range in a Content-Range header.
export async function uploadChunks(
  file: File,
  url: string,
  chunkSize = 5 * 1024 * 1024, // 5 MB
): Promise<void> {
  const plans = planChunks(file, chunkSize);
  for (const { index, start, end, blob } of plans) {
    const response = await fetch(url, {
      method: "PUT",
      headers: {
        "Content-Range": `bytes ${start}-${end - 1}/${file.size}`,
        "X-Chunk-Index": String(index),
        "X-Total-Chunks": String(plans.length),
      },
      body: blob, // the browser streams the slice; no full-file buffer
    });
    if (!response.ok) {
      throw new Error(`chunk ${index} failed: HTTP ${response.status}`);
    }
    console.log(`uploaded chunk ${index + 1}/${plans.length} (${end - start} bytes)`);
  }
}

Usage:

const input = document.querySelector<HTMLInputElement>("#file")!;
input.addEventListener("change", async () => {
  const file = input.files?.[0];
  if (file) await uploadChunks(file, "/api/upload");
});

Line-by-line on the critical parts

  • file.slice(start, end) follows the [start, end) half-open convention β€” end is exclusive β€” so consecutive ranges share no overlap and lose no bytes.
  • The slice is a Blob referencing the original file’s storage; it allocates almost nothing until consumed, so planChunks can describe a 4 GB file instantly.
  • Content-Range: bytes ${start}-${end - 1}/${file.size} reports an inclusive end, which is why it subtracts one β€” a frequent off-by-one source.
  • Passing the Blob directly as fetch body lets the browser stream it; never call arrayBuffer() first or you reintroduce the whole-file memory cost.

For an upload that survives reloads and reorders retries, drive this planner from a resumable upload state machine. If you instead need each chunk’s raw bytes (for client-side hashing), read them with FileReader and ArrayBuffer.

Splitting a file into byte-range chunks with Blob.slice A single file bar divided into four equal byte ranges, each sliced lazily and sent as a separate chunk. Byte-range chunking file (size = N bytes) chunk 0 0 – C chunk 1 C – 2C chunk 2 2C – 3C chunk 3 3C – N slice(start, end) is half-open: end is exclusive, so ranges never overlap.
A file split into equal byte ranges; each Blob.slice is lazy until the chunk is actually sent.

Choosing a chunk size

Chunk size Good for Trade-off
256 KB – 1 MB Flaky mobile networks More requests, more per-chunk overhead
5 MB General default; S3 multipart minimum part size Balanced retry cost vs request count
8 – 16 MB Fast, stable connections Larger re-send on a failed chunk; more peak memory if buffered

Start at 5 MB. It satisfies the S3 multipart minimum (5 MB for all but the last part) and keeps retry cost reasonable.

Configuration gotchas

Off-by-one in Content-Range. slice(start, end) uses an exclusive end, but the Content-Range header is inclusive. Send end - 1, not end, or the server sees overlapping or gapped ranges.

S3 parts under 5 MB are rejected. S3 multipart uploads require every part except the last to be at least 5 MB; smaller parts fail on CompleteMultipartUpload. Set chunkSize >= 5 * 1024 * 1024 for S3.

Calling arrayBuffer() on every chunk defeats the point. Reading each slice into memory before sending reintroduces full-file memory cost across concurrent chunks. Pass the Blob straight to fetch.

A zero-byte file produces no chunks. A naive loop skips empty files entirely, so the upload never finalizes. Emit a single empty chunk, as planChunks does.

Testing / verification

Assert the plan covers the file with no gaps or overlaps:

function verifyPlan(fileSize: number, chunkSize: number): void {
  const fakeFile = new Blob([new Uint8Array(fileSize)]);
  const plans = planChunks(fakeFile, chunkSize);
  let covered = 0;
  for (let i = 0; i < plans.length; i++) {
    console.assert(plans[i].start === covered, `gap before chunk ${i}`);
    console.assert(plans[i].blob.size === plans[i].end - plans[i].start, "blob size mismatch");
    covered = plans[i].end;
  }
  console.assert(covered === fileSize, "plan does not cover whole file");
  console.log("verified", plans.length, "chunks covering", fileSize, "bytes");
}

verifyPlan(12_345_678, 5 * 1024 * 1024);

FAQ

Does Blob.slice copy the file data?

No. slice() returns a lightweight Blob that references a byte range of the original; no bytes are read or copied until you consume the chunk (send it or call arrayBuffer()). That is why slicing a multi-gigabyte file is instant.

What chunk size should I use?

Default to 5 MB. It is the S3 multipart minimum and balances retry cost against request count. Drop to 256 KB–1 MB on unreliable mobile networks and raise to 8–16 MB only on fast, stable links.

Why does my server see overlapping byte ranges?

slice(start, end) treats end as exclusive, but Content-Range is inclusive. Send bytes ${start}-${end - 1}/${size} β€” forgetting the - 1 reports one byte too many per chunk.

How do I make chunked uploads resumable?

Track which chunk indexes succeeded and skip them on retry. A resumable upload state machine persists that state so a reload or network drop resumes instead of restarting.