Streaming JSON Parsing with Transferable Chunks
Read a large NDJSON or JSON-array response chunk by chunk, hand each chunk to a worker by transferring its ArrayBuffer instead of copying it, and parse records as the bytes land — so the first row renders while the download is still running.
This is one of the concrete techniques in the Data Parsing & Serialization section of High-Performance Computation Patterns. Where the neighbouring benchmark of JSON.parse against worker deserialization establishes when moving a parse off the main thread pays for itself, this page removes the overhead that made the answer close in the first place: the copy at the thread boundary, which disappears entirely when you send transferable objects rather than strings.
Why the Obvious Version Blocks
const data = await response.json() looks asynchronous and is not, in the part that matters. The download is asynchronous; the parse is a single synchronous V8 call that runs to completion on the main thread once every byte has arrived. For a 20 MB payload that is 55–75 ms of unbroken blocking — four frames dropped, every click queued — and it happens at the worst possible moment, right when the UI is about to update.
Moving the parse to a worker by posting the text does not fix it, because a string is not transferable. It is cloned, so you replace one main-thread stall with a smaller one and add a full second copy of the payload to the process. That is the crossover problem measured in the structured clone cost analysis: below a few megabytes the copy costs more than the parse it was meant to avoid.
Streaming changes the shape of the problem rather than the size of the constant. The main thread never holds the payload, never parses anything, and never copies anything larger than a chunk header:
| Strategy | Main-thread block | First record visible | Peak main-thread memory |
|---|---|---|---|
await response.json() |
55–75 ms | after the full parse | ~40 MB |
| Worker, full string cloned | ~20 ms (clone only) | after the full parse | ~40 MB |
| Streaming transferred chunks | under 2 ms total | after the first chunk (~50 ms) | one chunk (8–64 KB) |
Minimal Reproducible Example
Two files: the main thread pumps the network stream into the worker, the worker decodes and parses. Nothing else is required — no library, no build-time configuration beyond module worker support.
// main.ts — pump the response body into the worker
type FromWorker =
| { type: 'batch'; records: unknown[] }
| { type: 'progress'; bytesReceived: number; totalBytes: number }
| { type: 'ready' }
| { type: 'done'; count: number };
const worker = new Worker(new URL('./ndjson.worker.ts', import.meta.url), {
type: 'module',
});
// Backpressure: the reader waits for the worker to acknowledge each chunk.
let releaseChunk: (() => void) | null = null;
const workerReady = () =>
new Promise<void>((resolve) => {
releaseChunk = resolve;
});
worker.onmessage = ({ data }: MessageEvent<FromWorker>) => {
switch (data.type) {
case 'batch':
appendRows(data.records); // render incrementally
break;
case 'progress':
updateProgressBar(data.bytesReceived, data.totalBytes);
break;
case 'ready':
releaseChunk?.(); // worker drained this chunk
break;
case 'done':
console.log(`Parsed ${data.count} records`);
worker.terminate();
break;
}
};
/** A chunk's buffer is only safe to transfer when the view spans all of it. */
function detach(chunk: Uint8Array): ArrayBuffer {
return chunk.byteOffset === 0 && chunk.byteLength === chunk.buffer.byteLength
? chunk.buffer
: chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength);
}
export async function streamToWorker(
url: string,
signal: AbortSignal,
): Promise<void> {
const response = await fetch(url, { signal });
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
const totalBytes = Number(response.headers.get('Content-Length') ?? 0);
const reader = response.body.getReader();
let bytesReceived = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
bytesReceived += value.byteLength;
const buffer = detach(value);
const settled = workerReady();
// Transfer list → ownership moves, no bytes are copied.
worker.postMessage(
{ type: 'chunk', buffer, bytesReceived, totalBytes },
[buffer],
);
await settled; // do not outrun the parser
}
worker.postMessage({ type: 'end' });
} catch (err) {
if ((err as Error).name !== 'AbortError') throw err;
await reader.cancel();
worker.terminate();
}
}
// ndjson.worker.ts — decode and parse incrementally
const decoder = new TextDecoder('utf-8'); // strips a leading BOM
const BATCH_SIZE = 200;
let remainder = '';
let recordCount = 0;
function flushLines(text: string, final: boolean): void {
const lines = (remainder + text).split('\n');
remainder = final ? '' : (lines.pop() ?? ''); // partial line carried over
const batch: unknown[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
batch.push(JSON.parse(trimmed));
recordCount++;
} catch {
// A malformed line is data, not a crash: count it and keep going.
}
if (batch.length >= BATCH_SIZE) {
self.postMessage({ type: 'batch', records: batch.splice(0) });
}
}
if (batch.length) self.postMessage({ type: 'batch', records: batch });
}
self.onmessage = ({ data }: MessageEvent) => {
if (data.type === 'chunk') {
flushLines(decoder.decode(data.buffer as ArrayBuffer, { stream: true }), false);
self.postMessage({
type: 'progress',
bytesReceived: data.bytesReceived,
totalBytes: data.totalBytes,
});
self.postMessage({ type: 'ready' }); // release the next read
} else if (data.type === 'end') {
flushLines(decoder.decode(), true); // flush the decoder, then the line
self.postMessage({ type: 'done', count: recordCount });
self.close();
}
};
Line-by-Line Walkthrough
const { done, value } = await reader.read();
ReadableStreamDefaultReader.read() resolves once with each Uint8Array the network layer delivers. Chunk size is not yours to choose — it follows the TCP receive buffer and the HTTP/2 frame size, typically 8–64 KB, and varies within a single response. Every iteration of this loop yields to the event loop, which is what keeps the main thread interactive: the work between two awaits is a length check and a postMessage.
return chunk.byteOffset === 0 && chunk.byteLength === chunk.buffer.byteLength
? chunk.buffer
: chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength);
This is the only subtle line in the file. Transferring an ArrayBuffer detaches every view over it, not just the one you were holding. For a default reader on response.body each chunk owns its buffer outright, so the fast path transfers it as-is with no copy at all. A view that is a window into a larger buffer — what you get from a BYOB reader, or from a transform stream that emits subarray() results — must be copied out first, or the transfer detaches memory the stream still intends to write into and the next read() rejects with a TypeError. The guard picks the free path when it is available and pays one chunk-sized memcpy (microseconds at 64 KB) when it is not.
worker.postMessage({ type: 'chunk', buffer, ... }, [buffer]);
The second argument is the transfer list. Ownership of the buffer moves to the worker; afterwards buffer.byteLength is 0 on the main thread and reading it throws. The cost is a pointer hand-off plus the structured clone of the small envelope object around it — under 0.05 ms for a 64 KB chunk, and, crucially, independent of chunk size. Cloning the same 64 KB would cost roughly twice that and would scale linearly. The same ownership rules govern every buffer you send this way, including the typed arrays in passing large arrays without blocking the UI.
const decoder = new TextDecoder('utf-8');
decoder.decode(data.buffer as ArrayBuffer, { stream: true });
One decoder instance for the whole response, { stream: true } on every chunk. The option tells the decoder to retain internal state, so a multi-byte sequence split across a chunk boundary — the 0xC3 0xA9 of é arriving as 0xC3 at the end of chunk 7 and 0xA9 at the start of chunk 8 — is reassembled instead of being replaced by U+FFFD. The final decoder.decode() with no arguments flushes that state; skip it and a truncated response ends silently instead of surfacing a replacement character.
const lines = (remainder + text).split('\n');
remainder = final ? '' : (lines.pop() ?? '');
Chunk boundaries fall in the middle of records far more often than not. Prepending remainder and popping the last element leaves lines holding only complete records, with the fragment parked until the next chunk completes it. On the final call pop() is skipped so the last record — which has no trailing newline — is parsed rather than discarded.
Handling a Top-Level JSON Array
NDJSON is the format this pattern wants, because a newline is an unambiguous record boundary that costs nothing to find. A conventional [{…},{…}] document has no such marker, so the worker has to track brace depth itself while respecting string literals and escapes.
// array-stream.worker.ts — emit each top-level element of a JSON array
const decoder = new TextDecoder('utf-8');
let buf = '';
let depth = 0;
let inString = false;
let escaped = false;
let started = false;
let recordCount = 0;
function scan(): void {
let start = 0;
for (let i = 0; i < buf.length; i++) {
const ch = buf[i];
if (escaped) { escaped = false; continue; }
if (inString) {
if (ch === '\\') escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') { inString = true; continue; }
if (!started) { // skip whitespace before the opening [
if (ch === '[') { started = true; start = i + 1; }
continue;
}
if (ch === '{' || ch === '[') depth++;
else if (ch === '}' || ch === ']') {
depth--;
if (depth === 0) { // one complete top-level element
const json = buf.slice(start, i + 1).trim();
try {
self.postMessage({ type: 'record', record: JSON.parse(json) });
recordCount++;
} catch { /* skip a malformed element */ }
start = i + 1;
} else if (depth < 0) { // the array's own closing bracket
buf = '';
return;
}
}
}
buf = buf.slice(start).replace(/^[\s,]+/, ''); // drop the separating comma
}
self.onmessage = ({ data }: MessageEvent) => {
if (data.type === 'chunk') {
buf += decoder.decode(data.buffer as ArrayBuffer, { stream: true });
scan();
self.postMessage({ type: 'ready' });
} else if (data.type === 'end') {
buf += decoder.decode();
scan();
self.postMessage({ type: 'done', count: recordCount });
self.close();
}
};
Counting [ and ] alongside { and } is what lets this handle nested arrays — [[1,2],[3,4]] and [{"tags":["a","b"]}] both scan correctly, which a brace-only counter gets wrong. Throughput is close to the NDJSON path because the scan is a single pass over characters V8 keeps in a flat string, and the expensive step in both cases is JSON.parse.
A depth scanner is correct for a document that is one array of elements. The moment you need a specific path inside a nested document — every results[*].rows[*] from an envelope that also carries metadata — you are writing a JSONPath-aware parser, and a proven streaming parser such as clarinet or a WebAssembly SAX parser is the better use of the worker. Streaming machinery on this page is unchanged: only the consumer inside the worker swaps out.
Progress Reporting That Does Not Lie
bytesReceived / totalBytes is free but it is a download indicator, not a work indicator. It is missing entirely under Transfer-Encoding: chunked, where no Content-Length is sent, and it is actively wrong for compressed responses, where the header describes compressed bytes while the reader yields decompressed ones.
// Byte progress, guarded — total is 0 when the server streams without a length.
if (totalBytes > 0) {
const fraction = Math.min(bytesReceived / totalBytes, 1);
setProgress(fraction);
} else {
setIndeterminate(true); // spinner, not a bar that never reaches 100%
}
// Record progress, when the server can tell you the count up front.
const expected = Number(response.headers.get('X-Record-Count') ?? 0);
self.postMessage({ type: 'progress', parsed: recordCount, total: expected });
Record counts produce a visibly smoother bar than byte counts, because parse rate is far more uniform than byte arrival rate — TCP congestion control makes throughput lumpy in a way that record throughput is not. If you control the endpoint, emitting X-Record-Count is a two-line change that buys a better progress bar than any client-side smoothing.
Gotchas and Edge Cases
ignoreBOM: true does the opposite of what it sounds like. The default TextDecoder strips a leading U+FEFF; passing { ignoreBOM: true } tells it to ignore the BOM’s special meaning and pass the character through. A file exported from Excel or a .NET service starts with EF BB BF, so the first decoded line becomes {"id":1} and JSON.parse throws Unexpected token on record one while every other record parses. Leave the option off.
The reader can outrun the parser. postMessage never blocks, so a fast connection feeding a slow parse queues chunks in the worker’s message port faster than they are drained: the worker’s heap grows to the size of the whole payload and you have reinvented buffering with extra steps. The ready/await settled handshake above bounds the queue at one chunk. A pool consuming several streams at once needs the same discipline, sized as described in Worker Pool Management, or a lock-free ring buffer when the acknowledgement round trip itself becomes the bottleneck.
One message per record is the default mistake. Per-message overhead is roughly 20–40 µs once the port is saturated, so 200 000 individual records spend several seconds in message dispatch alone and the main thread does nothing but handle events. Batching at 100–500 records amortises it to noise; batches of 200 flat records are a few hundred kilobytes cloned in well under a millisecond.
Abort has to reach three places. Cancelling the fetch alone leaves the worker alive holding transferred chunks; terminating the worker alone leaves the socket draining. On unmount or navigation, call controller.abort(), reader.cancel() and worker.terminate() — and catch the AbortError that the pending read() rejects with, or it surfaces as an unhandled rejection in production telemetry.
Chunk boundaries respect nothing. Not UTF-8 sequences, not lines, not JSON tokens. Every stateful part of the worker — the decoder, the remainder, the depth counter — exists because a boundary can land anywhere. Test with a deliberately hostile chunker that slices the payload every 7 bytes; anything that survives that will survive the network.
Performance Note
On Chrome 124 / V8 12.4, a 20 MB NDJSON file of 200 000 flat records streamed through this pipeline keeps total main-thread blocking under 2 ms — roughly 300 chunks at well under 0.05 ms of transfer plus envelope clone each — against 55–75 ms for await response.json(). Inside the worker, JSON.parse on short flat records sustains 200–260 MB/s, so the worker is not the constraint on any connection slower than about 2 Gbit/s: the pipeline is network-bound by design, which is the point.
The rule of thumb: transfer cost is constant, clone cost is linear, so the payload size at which streaming wins is the size at which the copy stops being free — about 1 MB in practice. Below that, await response.json() inside one frame is simpler and no slower. Above it, the argument for streaming is not only throughput but latency to first paint, which no buffered approach can match at any size. Measure your own payload before choosing:
const t0 = performance.now();
await streamToWorker(url, controller.signal);
console.log(`wall clock ${(performance.now() - t0).toFixed(0)} ms`);
// Compare against the long-task entries, which is what users actually feel:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) console.log('long task', entry.duration);
}).observe({ type: 'longtask', buffered: true });
A correct implementation logs zero long tasks on the main thread for any payload size. If one appears, it is almost always rendering — appending 200 000 rows to the DOM — rather than parsing, and the fix belongs in the render layer.
In a framework component, create the worker and the AbortController in the effect that starts the stream and tear both down in its cleanup — controller.abort() then worker.terminate(). Without it, a user who navigates away mid-download leaves a worker parsing a payload nobody will see and posting batches into a state setter that no longer exists. Buffer incoming batches into a ref and commit them on an animation frame rather than calling the setter once per batch, or the render becomes the new bottleneck.