Slicing Large Files with Blob.slice
blob.slice(start, end, contentType) hands you a new Blob that points at a byte range of the original without reading, copying, or even opening the underlying file — so the way to upload a 4 GB video from a tab that never exceeds 40 MB of heap is to compute [start, end) offsets, slice each range at the moment you need it, and pass the resulting Blob straight to fetch as the request body.
This article sits under File API and Blob objects inside upload fundamentals and browser APIs. Slicing is the primitive that every resumable, parallel and chunked upload is built on, so it is worth knowing exactly what the method does and — more usefully — what it silently does not complain about.
When to use this approach
- The file is large enough that one request risks a proxy timeout, a memory spike, or losing 90% of a transfer to a single dropped connection. Below roughly 100 MB a single request is usually cheaper; the break-even analysis lives in multipart vs single-PUT for files under 100MB.
- You need per-chunk retry, so a transient 502 costs one part rather than the whole upload.
- You are driving a protocol with fixed-size parts (S3 multipart) or an offset cursor (tus, GCS resumable sessions).
- You need the bytes themselves in range-sized pieces — for progressive hashing, magic-byte sniffing of the first 4 KB, or reading a trailing index.
If instead you want the browser to feed the socket continuously from one open request, look at uploading with ReadableStream request bodies — that is a different shape of solution to the same memory problem.
Prerequisites
- A
FileorBlobreference from an<input type="file">or a drag-and-drop drop zone. - An endpoint that accepts ranged writes: a
Content-Range-aware handler, a tus server, or presigned S3 multipart part URLs. - TypeScript 5.x with
"lib": ["DOM", "DOM.Iterable", "ES2022"], or plain ESM in any browser released after 2015. The vendor-prefixedwebkitSliceandmozSlicehave not been needed since Firefox 13 and Chrome 21.
How slice() actually works
The File API specification defines slice() as pure arithmetic over three numbers. It never touches the backing store, never validates that the range makes sense, and never throws.
The algorithm normalises start and end against blob.size in the same way Array.prototype.slice does. A missing argument becomes 0 or size. A negative argument counts back from the end: relativeStart = max(size + start, 0). An argument past the end is clamped: relativeEnd = min(end, size). The resulting span is max(relativeEnd - relativeStart, 0), which is why an inverted range gives you a zero-byte Blob rather than an exception.
Two properties of the returned object matter in production:
const blob = new Blob([new Uint8Array(1000)]);
console.log(blob.slice(0, 250).size); // 250
console.log(blob.slice(-100).size); // 100 — the trailing 100 bytes
console.log(blob.slice(750, 5000).size); // 250 — end clamped to size
console.log(blob.slice(600, 200).size); // 0 — inverted range, still no throw
console.log(blob.slice().size); // 1000 — a whole-blob view
console.log(blob.slice(0, 10, "text/csv").type); // "text/csv"
console.log(blob.slice(0, 10).type); // "" — type is NOT inherited
First, the slice shares the original’s reference-counted backing store, so it costs a few dozen bytes of heap regardless of span. Slicing a 4 GB file into 512 parts produces 512 tiny objects and zero disk I/O; the read happens later, once, when something consumes the chunk.
Second, type is not inherited. The third argument is the only way to set it, and if you omit it the slice reports "". That single line is responsible for more presigned-URL signature failures than any other detail on this page.
The negative-offset form is more than a curiosity. file.slice(-22) reads the End of Central Directory record of a ZIP without touching the other 3 GB; file.slice(0, 4100) is enough for magic-byte detection on almost every container format.
Implementation
Keep the plan as plain numbers and create the Blob only at the moment of sending. Numbers serialise into IndexedDB, survive a reload, and can be diffed against what the server says it has; live Blob objects cannot.
export interface ChunkPlan {
index: number;
start: number; // inclusive
end: number; // exclusive
size: number;
}
export interface SliceUploadOptions {
chunkSize?: number;
contentType?: string;
signal?: AbortSignal;
startOffset?: number;
onProgress?: (sentBytes: number, totalBytes: number) => void;
}
/** Pure arithmetic. Allocates no Blobs and reads no bytes. */
export function planChunks(total: number, chunkSize: number, from = 0): ChunkPlan[] {
if (!Number.isInteger(chunkSize) || chunkSize <= 0) {
throw new RangeError(`chunkSize must be a positive integer, got ${chunkSize}`);
}
if (!Number.isInteger(from) || from < 0 || from > total) {
throw new RangeError(`startOffset ${from} is outside 0..${total}`);
}
const plans: ChunkPlan[] = [];
for (let start = from, index = 0; start < total; start += chunkSize, index += 1) {
const end = Math.min(start + chunkSize, total);
plans.push({ index, start, end, size: end - start });
}
// A zero-byte file still needs one request or the server never finalises it.
if (plans.length === 0 && total === 0) {
plans.push({ index: 0, start: 0, end: 0, size: 0 });
}
return plans;
}
export async function uploadInSlices(
file: File,
url: string,
options: SliceUploadOptions = {},
): Promise<void> {
const {
chunkSize = 8 * 1024 * 1024,
contentType = file.type || "application/octet-stream",
signal,
startOffset = 0,
onProgress,
} = options;
const plans = planChunks(file.size, chunkSize, startOffset);
let sent = startOffset;
for (const { index, start, end, size } of plans) {
// Slice here, not in planChunks: the handle is created and dropped per iteration.
const chunk = file.slice(start, end, contentType);
const response = await fetch(url, {
method: "PATCH",
signal,
headers: {
// Content-Range is inclusive at both ends; slice()'s end is exclusive.
"Content-Range": `bytes ${start}-${Math.max(end - 1, 0)}/${file.size}`,
"Upload-Offset": String(start),
},
body: chunk, // the network stack streams from the file; the heap stays flat
});
if (!response.ok) {
throw new Error(
`chunk ${index} (bytes ${start}-${end - 1}) failed: ` +
`HTTP ${response.status} ${response.statusText}`,
);
}
sent += size;
onProgress?.(sent, file.size);
}
}
Wiring it to an input is three lines:
const input = document.querySelector<HTMLInputElement>("#file");
input?.addEventListener("change", async () => {
const file = input.files?.[0];
if (!file) return;
const controller = new AbortController();
await uploadInSlices(file, "/api/upload/session-1", {
signal: controller.signal,
onProgress: (sent, total) => console.log(`${((sent / total) * 100).toFixed(1)}%`),
});
});
Line-by-line on the critical parameters
planChunks(total, chunkSize, from)takes a number, not aFile. That keeps it unit-testable in Node and lets you rebuild an identical plan after a page reload from nothing but the persisted size.- The loop’s
end = Math.min(start + chunkSize, total)is what produces the short final chunk. Never let the last part be a fullchunkSize— S3 rejects an over-declared part and aContent-Rangepastfile.sizeis a protocol error. file.slice(start, end, contentType)passes the type through deliberately. When you hand aBlobtofetchasbody, the browser derives theContent-Typeheader fromblob.type; an empty type means no header at all.Math.max(end - 1, 0)guards the zero-byte case. Without it an empty file emitsbytes 0--1/0, which Nginx answers with400 Bad Request.body: chunk— neverbody: await chunk.arrayBuffer(). The moment you materialise the buffer you paychunkSizeof heap per in-flight request, and with four concurrent 16 MB parts that is 64 MB of avoidable pressure. If you genuinely need the bytes, FileReader and ArrayBuffer covers the read path and its progress events.signalpropagates to every request, so onecontroller.abort()cancels the in-flight chunk and the loop rejects withAbortError. Pairing that with a per-chunk deadline is covered in aborting uploads with AbortController and timeouts.
Choosing a chunk size
Chunk size is a two-sided cost. Small chunks multiply per-request overhead — a TLS-resumed round trip, headers, server-side session lookup, and on HTTP/1.1 a queue behind the six-connection-per-origin limit. Large chunks multiply the cost of a failure, because a drop 90% of the way through a 128 MB part throws away 115 MB.
Configuration reference
| Option | Type | Default | Effect |
|---|---|---|---|
chunkSize |
number (bytes) |
8388608 (8 MB) |
Bytes per request. Must be ≥ 5 MB for S3 multipart parts other than the last. |
contentType |
string |
file.type || "application/octet-stream" |
Sets the slice’s type, which becomes the request Content-Type. Must match what a presigned URL was signed with. |
startOffset |
number (bytes) |
0 |
First byte to send. Set from the server’s reported offset when resuming. |
signal |
AbortSignal |
undefined |
Cancels the in-flight chunk; the loop rejects with AbortError. |
onProgress |
(sent, total) => void |
undefined |
Fires once per completed chunk. Chunk-granular, not byte-granular — see showing accurate time-remaining estimates. |
Practical defaults: 8 MB on desktop, 2 MB when navigator.connection?.effectiveType reports 3g or worse, and never below 5 MB when the destination is S3 multipart.
Resuming: part index versus byte offset
Where teams lose the most bytes is on resume. The server tells you it holds 22,544,384 bytes; your plan is in 8 MB parts; that offset falls in the middle of part 3. What you do next depends entirely on the protocol.
For an offset protocol, ask the server where it is and re-plan from there:
export async function resumeUpload(
file: File,
url: string,
chunkSize = 8 * 1024 * 1024,
): Promise<void> {
const probe = await fetch(url, { method: "HEAD" });
if (probe.status === 404) {
await uploadInSlices(file, url, { chunkSize });
return;
}
if (!probe.ok) {
throw new Error(`cannot probe session: HTTP ${probe.status}`);
}
const raw = probe.headers.get("Upload-Offset");
const offset = raw === null ? 0 : Number.parseInt(raw, 10);
if (!Number.isInteger(offset) || offset < 0 || offset > file.size) {
throw new Error(`server reported an unusable Upload-Offset: ${String(raw)}`);
}
if (offset === file.size) {
console.log("already complete; nothing to send");
return;
}
console.log(`resuming at byte ${offset} of ${file.size}`);
await uploadInSlices(file, url, { chunkSize, startOffset: offset });
}
Note that Upload-Offset must be read from a response header, which means the server has to expose it through Access-Control-Expose-Headers or probe.headers.get() returns null on a cross-origin session — a failure that looks exactly like “the server lost my upload”. Persisting the offset locally as well, via a resumable upload state machine, gives you a second source of truth.
With S3 multipart you cannot resume mid-part, but you also do not need to: call ListParts, keep the PartNumber/ETag pairs you already have, and re-slice only the missing indices. Sessions that never complete leak storage, so pair it with expiring incomplete multipart uploads automatically.
Configuration gotchas
The sliced Blob has no type, and the signature fails
Presign a PUT with ContentType: "video/mp4", send file.slice(0, size) without the third argument, and S3 answers 403 Forbidden with <Code>SignatureDoesNotMatch</Code><Message>The request signature we calculated does not match the signature you provided. Check your key and signing method.</Message>. The slice’s type is "", so the browser sends no Content-Type header, so the canonical request the server rebuilds differs from the one you signed. Always pass the third argument, and match it to what you passed when generating the presigned URL with AWS SDK v3.
Off-by-one between slice() and Content-Range
slice(start, end) has an exclusive end; Content-Range: bytes first-last/total has an inclusive last. Send end instead of end - 1 and each chunk claims one byte it did not send. Nginx replies 400 Bad Request and a strict handler will assemble a file one byte longer per chunk than the original — a corruption that only surfaces when a checksum finally runs.
Parts under 5 MB are rejected at completion, not at upload
Every S3 multipart part except the last must be at least 5,242,880 bytes. Individual UploadPart calls succeed regardless; the failure lands at the end as 400 Bad Request with <Code>EntityTooSmall</Code><Message>Your proposed upload is smaller than the minimum allowed size</Message>. Validate chunkSize >= 5 * 1024 * 1024 before the first request rather than after the last one.
The file changed on disk mid-upload
A File is a snapshot of a path plus a lastModified timestamp — it is not a lock. If the user re-exports the video while it is uploading, the next slice you consume fails. Chrome logs Failed to load resource: net::ERR_UPLOAD_FILE_CHANGED and the fetch rejects with a bare TypeError: Failed to fetch; Firefox surfaces NS_ERROR_FILE_NOT_FOUND. There is no event to subscribe to, so treat a TypeError from a chunk request as unrecoverable, tear the session down, and ask the user to re-select the file — do not feed it into the backoff loop described in implementing exponential backoff for failed chunks.
A chunk that is fine for the browser is too big for the proxy
Slicing does nothing about body-size limits between you and the origin. An 8 MB chunk through a default Nginx client_max_body_size 1m returns 413 Request Entity Too Large on every part; the fix and the matching Cloudflare limits are in handling 413 and 507 errors during uploads.
Verification
Two assertions catch nearly every planning bug. The first is pure arithmetic and runs in Node; the second proves the slices actually reassemble byte-for-byte and should run only against a small fixture.
export function assertPlanCoversFile(total: number, chunkSize: number, from = 0): void {
const plans = planChunks(total, chunkSize, from);
let cursor = from;
for (const plan of plans) {
if (plan.start !== cursor) throw new Error(`gap or overlap at chunk ${plan.index}`);
if (plan.size !== plan.end - plan.start) throw new Error("size disagrees with range");
if (plan.size <= 0 && total > 0) throw new Error(`empty chunk ${plan.index}`);
cursor = plan.end;
}
if (cursor !== total) throw new Error(`plan ends at ${cursor}, file is ${total}`);
console.log(`ok: ${plans.length} chunks cover bytes ${from}..${total}`);
}
export async function assertSlicesReassemble(file: Blob, chunkSize: number): Promise<void> {
const plans = planChunks(file.size, chunkSize);
const rebuilt = new Blob(plans.map((p) => file.slice(p.start, p.end)));
if (rebuilt.size !== file.size) throw new Error("reassembled size differs");
const [left, right] = await Promise.all([file.arrayBuffer(), rebuilt.arrayBuffer()]);
const a = new Uint8Array(left);
const b = new Uint8Array(right);
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) throw new Error(`byte ${i} differs after slicing`);
}
console.log(`ok: ${plans.length} slices reassemble to ${file.size} identical bytes`);
}
assertPlanCoversFile(524_288_000, 8 * 1024 * 1024);
// ok: 63 chunks cover bytes 0..524288000
assertPlanCoversFile(524_288_000, 8 * 1024 * 1024, 22_544_384);
// ok: 60 chunks cover bytes 22544384..524288000
In DevTools, open the Network panel and sort by size: every request should report exactly 8,388,608 B except the last, and the Memory panel’s JS heap should stay flat across the whole transfer. If the heap tracks upload progress, something is calling arrayBuffer(). For end-to-end integrity rather than plan correctness, hash the file with Web Crypto in the browser and compare against what the server computed.
Frequently Asked Questions
Does Blob.slice copy the file data?
No. It creates a new Blob that references a range of the same reference-counted backing store, so the call costs a few dozen bytes of heap and zero disk I/O whatever the span. Bytes are read once, later, when something consumes the chunk — sending it, streaming it, or calling arrayBuffer().
Why is the Content-Type header missing on my chunk requests?
Because a sliced Blob does not inherit type from its parent; it is "" unless you pass the third argument. Use file.slice(start, end, file.type || "application/octet-stream") so fetch has something to derive the header from.
Can I slice a file bigger than 2 GB?
Yes — size, start and end are ordinary JavaScript numbers, exact up to 9 petabytes, and the slice itself is arithmetic. The 2 GB wall is on arrayBuffer(), which rejects with RangeError: Array buffer allocation failed well before that. Slice and stream, never buffer.
Should each chunk be a separate request, or one streamed request?
Separate requests give you per-chunk retry and resume, which is why every resumable protocol works that way. One streamed body is fewer round trips but an all-or-nothing transfer; the comparison is worked through in multipart vs single-PUT for files under 100MB.
How many chunks can I upload in parallel?
Over HTTP/1.1 the browser caps you at six connections per origin, so more than six in flight just queues. Four is a good default: it saturates most consumer links, keeps the retry blast radius small, and leaves connections free for the progress and session-refresh calls your app still needs to make.