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
- A
File/Blobreference from an<input>or a drag-and-drop drop zone. - An endpoint that accepts ranged chunks (a chunked API, or S3 multipart parts).
- 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 βendis exclusive β so consecutive ranges share no overlap and lose no bytes.- The slice is a
Blobreferencing the original fileβs storage; it allocates almost nothing until consumed, soplanChunkscan 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
Blobdirectly asfetchbodylets the browser stream it; never callarrayBuffer()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.
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.