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)
Three strategies for a 20 MB NDJSON response, on one time axis await response.json() downloads for 260 ms then blocks the main thread for 65 ms before the first record appears. Sending the response as a cloned string adds a 20 ms clone before the worker parses, so the first record appears at 355 ms. Streaming transferred chunks overlaps the download with worker parsing, spends under 2 ms in total on the main thread, and shows the first record at about 50 ms. network download main thread blocked worker parsing await response.json() 65 ms of unbroken main-thread block download 20 MB parse first record worker, string cloned 20 ms clone — a string cannot be transferred download 20 MB worker parse first record streaming transferred chunks first record at ~50 ms ~300 transfers · under 2 ms total worker decodes and parses as bytes land 0 100 ms 200 ms 300 ms 400 ms
The same 20 MB response under all three strategies. Buffering moves the block to the worst moment; cloning shrinks it without removing it; streaming replaces one long block with ~300 sub-millisecond transfers and paints the first row while the download still has 210 ms to run.

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.

The journey of one 64 KB chunk across the thread boundary On the main thread: reader.read() yields a Uint8Array view over an ArrayBuffer; detach() transfers the buffer as-is when the view spans it, or slices a copy when it does not; postMessage sends it with a transfer list and leaves the local view detached with byteLength zero. In the worker: TextDecoder in stream mode keeps the trailing bytes of a split UTF-8 sequence, the remainder string keeps the trailing partial line, and complete records are posted back in batches of 200 while an acknowledgement releases the next read. MAIN THREAD 1 · reader.read() ArrayBuffer · 64 KB a Uint8Array view over it 2 · detach(chunk) spans the buffer → transfer as-is windowed view → slice() a copy 3 · postMessage(…, [buffer]) detached · byteLength 0 the local view is dead on arrival appendRows() paints as it streams thread boundary buffer ownership moves — no bytes copied WORKER 4 · decode(buf, {stream: true}) chunk 7 ends with C3 … … chunk 8 starts A9 → é decoder state survives the boundary 5 · remainder + split() complete lines → JSON.parse tail fragment → remainder carried into the next chunk 6 · batch of 200 records postMessage({ batch }) then 'ready' releases the read backpressure closes the loop
One chunk, end to end. Only the envelope object is cloned; the 64 KB payload changes owner without moving. Every piece of worker state — the decoder's trailing bytes, the remainder line — exists because the chunk boundary landed mid-character and mid-record.

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.

Know when to stop hand-rolling

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.

The array scanner as a five-state machine The scanner starts Outside and skips to the opening bracket. In array at depth zero it waits for a brace or bracket, which moves it to In element at depth one or more. Nested brackets only move the depth counter; when depth returns to zero the completed slice is parsed and emitted and the scanner returns to depth zero. A quote moves it to In string, where braces are ordinary bytes, and a backslash moves it to Escaped, which consumes exactly one character and returns. Outside skip until [ In array depth 0 In element depth ≥ 1 Emit JSON.parse(slice) In string braces are just bytes Escaped consume 1 char on [ on { [ depth → 0 emit the element, then keep scanning on " on closing " on \ one char consumed Nested { [ } ] only move the depth counter — they never emit, which is what a brace-only scanner gets wrong. A quote suspends counting entirely, so a { inside a string literal cannot open a phantom element.
The five states the scanner keeps between chunks. Only the transition back from Emit produces a record; everything else is bookkeeping that has to survive a chunk boundary landing anywhere, including inside a string literal.

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.

What each progress indicator reads at five moments of the same response A bar driven by bytes received over Content-Length stalls at 15 per cent for a second, jumps to 62 then 100 per cent, and sits at 100 per cent while records are still being parsed. A bar driven by records parsed over an X-Record-Count header advances evenly through 20, 44, 68, 90 and 100 per cent. Under chunked or compressed transfer there is no usable total at all, so the only honest control is an indeterminate one. elapsed → 0.5 s 1.5 s 2.5 s 3.5 s complete bytes ÷ Content-Length 15% 15% — stalled 62% 100% — still parsing 100% Lumpy by construction: TCP decides the rate, and the bar reaches the end before the work does. records ÷ X-Record-Count 20% 44% 68% 90% 100% — done Parse rate is far steadier than byte arrival, so the same response produces an evenly filling bar. chunked — no total sent indeterminate — the position carries no information No Content-Length and no record count: show a spinner, never a bar that cannot reach the end.
The same response, three indicators. Byte progress is free and dishonest; record progress needs one header from the server and tracks the work the user is waiting for; with neither available, an indeterminate control is the only truthful option.

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.

Four ways a streaming JSON worker goes wrong One: a byte-order mark left in the stream makes JSON.parse throw on the first record only, fixed by leaving ignoreBOM off. Two: an unacknowledged reader queues chunks faster than the worker drains them and the worker heap grows to the whole payload, fixed by awaiting a ready acknowledgement. Three: posting one message per record spends seconds in dispatch, fixed by batching one hundred to five hundred records. Four: aborting the fetch alone leaves the worker and the socket alive, so all three of abort, cancel and terminate must run. 1 · The BOM that breaks record one EF BB BF 7B 22 69 64 22 3A 31 7D line 1 throws, lines 2…n parse fine An Excel or .NET export starts with those three bytes. Fix: leave ignoreBOM off — the default strips it for you. 2 · The reader outruns the parser reader worker unbounded port queue postMessage never blocks, so the worker heap grows to the size of the whole payload — buffering with extra steps. Fix: await a 'ready' acknowledgement before the next read(). 3 · One message per record 200 000 messages × 20–40 µs ≈ 4–8 s 1 000 batches of 200 ≈ 30 ms The main thread does nothing but handle message events. Fix: batch 100–500 records per postMessage. 4 · Abort must reach three places controller.abort() reader.cancel() worker.terminate() Cancel the fetch alone and the worker keeps parsing; kill the worker alone and the socket keeps draining. Fix: run all three, and catch the AbortError from read().
Four bugs that survive a happy-path test and only surface against a real payload, a real connection, or a user who navigates away mid-download.

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.

Tie the stream to the component lifecycle

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.

Cost per operation, log scale, against one frame Transferring a 64 KB chunk costs about 0.04 ms and structured-cloning the same 64 KB about 0.1 ms. Inside the worker, JSON.parse of a 64 KB chunk costs about 0.25 ms and cloning a batch of 200 parsed records back about 0.3 ms. A single await response.json() on a 20 MB payload costs about 65 ms — roughly four times the 16.7 ms frame budget, and the only one of the five that runs on the main thread as one unbroken task. 16.7 ms frame budget transfer a 64 KB chunk structured-clone 64 KB JSON.parse a 64 KB chunk clone a batch of 200 records await response.json() · 20 MB 0.04 ms · main thread 0.10 ms 0.25 ms · in the worker 0.30 ms 65 ms 0.01 0.1 1 10 100 milliseconds, log scale per 64 KB chunk, or per batch
Every cost the streaming pipeline pays is three orders of magnitude below the one it avoids — and each of the first four is a separate task, so none of them can merge into a frame-breaking block.

Frequently Asked Questions

Why transfer ArrayBuffer chunks instead of sending the response text to the worker?
Because a string cannot be transferred — it can only be cloned. postMessage(text) copies the whole string into the worker’s heap, so a 20 MB response is a 20 MB blocking copy that happens after the download has fully completed, and the worker cannot start until it finishes. Network chunks arrive as Uint8Array views over an ArrayBuffer, which is transferable: passing the buffer in the transfer list moves ownership in constant time regardless of size, and the worker decodes the bytes incrementally with TextDecoder in stream mode. Peak main-thread memory drops from the size of the payload to the size of one chunk.
Does the pattern still work when the response is gzip or brotli encoded?
Yes, and you do not have to do anything. The browser decompresses Content-Encoding: gzip and Content-Encoding: br before response.body is exposed, so the reader always yields raw UTF-8 bytes. Two things change: Content-Length describes the compressed size, so a progress bar built on it will run to 100% early and then keep receiving data — read X-Uncompressed-Length or count records instead. And if you are fetching a .gz file served without the matching header, the browser will not decompress it for you; pipe the body through DecompressionStream('gzip') before the reader and the rest of the pipeline is unchanged.

See also