Processing Large Files with Blob.slice in Workers
file.arrayBuffer() on a 4 GB upload is a request for 4 GB of contiguous heap. It fails on most phones, fails on many laptops, and on the machines where it succeeds it costs a multi-second stall. slice() is the alternative, and it is free.
This guide is the file-input case of Streaming & Backpressure Across Threads, part of High-Performance Computation Patterns. Slicing gives you explicit control over how much of a file is resident at once, plus the ability to hand different ranges to different workers — something a single sequential stream cannot do.
The Bounded Read Loop
// hash.worker.ts — read a file in 8 MB slices, never more than one resident
const SLICE = 8 * 1024 * 1024;
self.addEventListener('message', async (event: MessageEvent<{ file: File }>) => {
const { file } = event.data; // File objects are cloneable
const stamp = file.lastModified; // detect an edit mid-read
const digest = createRollingDigest();
let offset = 0;
try {
while (offset < file.size) {
const end = Math.min(offset + SLICE, file.size);
// slice() is a descriptor — nothing is read until arrayBuffer() resolves.
const bytes = new Uint8Array(await file.slice(offset, end).arrayBuffer());
digest.update(bytes);
offset = end;
self.postMessage({ kind: 'PROGRESS', done: offset, total: file.size });
}
if (file.lastModified !== stamp) throw new Error('file changed during read');
self.postMessage({ kind: 'DONE', digest: digest.hex() });
} catch (error) {
self.postMessage({ kind: 'ERROR', message: (error as Error).message });
}
});
Peak memory here is one slice — 8 MB — regardless of whether the file is 40 MB or 40 GB. The loop is sequential by construction: the next slice is not requested until the previous one has been processed, which is backpressure obtained by not asking for more.
Line-by-Line
File objects survive postMessage. A File (and any Blob) is structured-cloneable, and cloning one does not copy its contents — the clone references the same underlying data. Passing the file to the worker and slicing there is therefore the correct division: the page never allocates a byte.
slice(start, end) allocates nothing. It returns a Blob describing a range. Creating every slice up front to build a work list is entirely reasonable; the cost arrives only at arrayBuffer().
await slice.arrayBuffer() is the read. This is where disk I/O happens and where the memory appears. It is also the only line that can fail with a NotReadableError, and the failure is asynchronous, so it must be caught rather than guarded.
file.lastModified bracketing. A File is a reference to a path, not a lock. Checking the timestamp before and after a long read turns “silently produced a hash of half the old file and half the new one” into a clean error.
Progress per slice, not per record. One message per 8 MB slice is 500 messages for a 4 GB file — smooth for a progress bar and negligible for the transport. Reporting per record would be millions of hops.
Records That Straddle Slice Boundaries
Byte ranges do not respect record boundaries: a slice ends mid-line, mid-JSON-object, mid-UTF-8-codepoint. Three techniques cover essentially every format.
Carry the tail. For newline-delimited data, keep the bytes after the last delimiter and prepend them to the next slice. Decode with a streaming decoder so a multi-byte character split across the boundary is handled for you:
const decoder = new TextDecoder('utf-8');
let tail = '';
for (const [start, end] of ranges) {
const buf = await file.slice(start, end).arrayBuffer();
// `stream: true` keeps a partial code point for the next call.
const text = tail + decoder.decode(new Uint8Array(buf), { stream: true });
const lines = text.split('\n');
tail = lines.pop() ?? '';
for (const line of lines) handle(line);
}
if (tail) handle(tail);
Overlap and discard. For fixed-maximum-length records, read SLICE + maxRecordLength bytes and stop at the last complete record; the next slice starts where the last complete record ended. Costs a little duplicate I/O and needs no carried state, which makes it the right choice when slices are processed in parallel.
Align to the format. Fixed-width records, or containers with an index — Parquet, many binary logs — let you compute boundaries exactly. Where the format allows it this is strictly best: no overlap, no carried state, and slices are independent, which is what makes a pool usable.
Parallel Slices Across a Pool
Once slices are independent, the file becomes an embarrassingly parallel workload. The dispatcher hands ranges — not bytes — to each worker, and each worker reads its own range:
// page.ts — the file object is cloned to each worker; no bytes are copied
const RANGES = 8 * 1024 * 1024;
const ranges: Array<[number, number]> = [];
for (let o = 0; o < file.size; o += RANGES) ranges.push([o, Math.min(o + RANGES, file.size)]);
const results = await pool.mapOrdered(ranges, (worker, [start, end]) =>
worker.request({ kind: 'SCAN', file, start, end }));
Two constraints decide whether this pays. Disk throughput is shared, so on a spinning disk or a slow SD card four readers are not four times faster and may be slower due to seeking; on an SSD they usually are. And the aggregation must be associative — counts, sums, min/max, histograms and most digests combine fine; anything that depends on order needs the results reassembled by range index first.
Concurrency also multiplies the memory ceiling: poolSize × sliceSize. Four workers with 8 MB slices is 32 MB, which is fine; eight workers with 64 MB slices is half a gigabyte, which is not. Size the slice after choosing the pool size, following Worker Pool Management.
File System Access and the Origin Private File System
Two newer APIs change the arithmetic for repeat processing, and both are worker-friendly.
FileSystemFileHandle (from showOpenFilePicker()) is a durable reference the page can keep across sessions, and it is structured-cloneable, so it can be sent to a worker. Calling await handle.getFile() produces a fresh File snapshot, which is the clean answer to the stale-handle problem: re-snapshot before a long job instead of holding a File for minutes.
The origin private file system goes further. Inside a worker — and only inside a worker — createSyncAccessHandle() gives synchronous, seekable reads and writes over a file the origin owns:
// opfs.worker.ts — synchronous random access, worker-only by design
const root = await navigator.storage.getDirectory();
const handle = await root.getFileHandle('scratch.bin', { create: true });
const access = await handle.createSyncAccessHandle();
const view = new Uint8Array(8 * 1024 * 1024);
const read = access.read(view, { at: offset }); // no promise, no copy through a Blob
access.close();
That synchronous shape is why the API is restricted to workers: it would block the main thread outright. For workloads that pass over the same data repeatedly — building an index, then querying it — writing a normalised copy into the origin private file system once and reading it back synchronously is typically several times faster than re-slicing the user’s original file each pass, and it survives a page reload.
The trade is storage quota and the need to invalidate the derived copy when the source changes. Both are ordinary cache concerns; neither changes the slicing strategy above, which still applies to the first pass over the user’s file.
Gotchas
FileReader instead of arrayBuffer(). The callback-based FileReader still works and is strictly worse: it needs an event dance, it is easy to leak, and its onload closure captures the buffer. blob.arrayBuffer() is promise-based and available in workers everywhere.
Slices of slices are cheap, but the arithmetic is relative. blob.slice(a, b).slice(c, d) addresses c–d within the first slice. Convenient for framing a record, and a source of off-by-megabyte errors when the two coordinate systems get mixed.
Mobile memory ceilings are lower than they look. iOS terminates a tab that allocates too aggressively without a catchable error. Keeping slices at 4–8 MB and concurrency at two on mobile avoids the class of crash entirely.
Holding every slice’s result until the end. Bounding the input is pointless if the outputs accumulate: a scan that keeps one parsed object per row ends up with the whole file on the heap in a different shape. Aggregate incrementally, or stream the results onward under their own flow control.
Progress that reports read bytes, not processed bytes. With a pool, slices complete out of order, so a progress bar driven by “highest offset seen” jumps and stalls. Track the sum of completed slice sizes instead.
The file picker’s File can outlive its validity. A handle retained in application state for minutes may reference a file the user has since replaced. Re-check lastModified before a long job, not only during it.
Performance Note
Hashing a 4 GB file on a 2023 laptop with an NVMe drive: a single worker with 8 MB slices sustained 1.4 GB/s and held 8 MB resident. Four workers over independent ranges reached 2.9 GB/s — sublinear, because the digest itself is CPU-bound and the drive is shared — at 32 MB resident. The whole-file arrayBuffer() approach could not be measured on the same machine: it failed to allocate.