Data Parsing & Serialization

Deserializing a large API response is one of the few remaining places where a modern single-page app still blocks its own render thread for tens of milliseconds at a time. JSON.parse, schema validation, log tokenization, and binary header decoding are all synchronous, CPU-bound, and impossible to yield out of once started. This guide covers moving that work into a dedicated worker, and it is a specialisation of the broader High-Performance Computation Patterns reference — the thread-isolation, transfer-list, and lifecycle rules described there apply here unchanged.

The Bottleneck: Where Parse Time Actually Goes

The symptom is specific and easy to recognise in a trace: a user hits “load report”, the network request resolves, and then the browser paints nothing for 150–250 ms while a single Long Task sits in the flame chart labelled JSON.parse. Scroll stalls, the spinner freezes mid-rotation, and any click queued during that window is dispatched late.

Measured on a mid-tier laptop (Chrome 126, V8 12.6), a JSON document of 8 MB containing roughly 120,000 objects costs about:

Stage Cost Runs on
response.text() 12–20 ms Main thread (async, but the decode itself is a task)
JSON.parse 90–180 ms Main thread, fully synchronous
Schema validation (per-record checks) 40–120 ms Main thread, fully synchronous
Mapping to view models 30–90 ms Main thread, fully synchronous

Three of those four stages have no yield point. Chunking them with setTimeout only converts one long block into many medium ones and adds scheduler latency. The durable fix is to run them in a different thread entirely, so the render thread keeps its full 16.7 ms frame budget while a background thread does the work.

Two things make this a non-trivial refactor rather than a one-line change. First, moving data across the boundary is not free: a naive postMessage(jsonString) structured-clones the whole string, which reintroduces a main-thread copy proportional to payload size. Second, whatever the worker returns must also cross back — and returning 120,000 parsed objects can cost more than the parse you just eliminated. Both problems are solved by choosing the right transfer strategy, covered in detail below and in Transferable Objects & Zero-Copy.

Main-thread timeline before and after moving parsing to a worker Two timelines over the same 240 millisecond window. Without a worker, the main thread runs decode, JSON.parse for 110 milliseconds, validation for 60 milliseconds and view-model mapping for 40 milliseconds as one 228 millisecond Long Task, and every 16.7 millisecond frame in that window is dropped. With a parse worker, the main thread spends about 0.6 milliseconds on a transferring postMessage and then sits idle while the worker runs decode, parse, validate and summarise in parallel, so every frame still paints. frame painted dropped Without a worker — parse blocks the render thread frames Main thread decode JSON.parse 110 ms validate 60 ms map 40 ms one 228 ms Long Task — every frame in this window is dropped With a parse worker — the render thread stays free frames Main thread postMessage(buf, [buf]) ≈ 0.6 ms — then idle Worker TextDecoder → JSON.parse → validate → summarise transferred, zero-copy reply: aggregates + 100 rows 0 60 ms 120 ms 180 ms 240 ms
The same 8 MB payload on one timeline. Synchronously, decode plus parse plus validate plus mapping fuse into a single 228 ms Long Task and every frame in the window is lost; offloaded, the main thread spends ≈0.6 ms handing over a transferable ArrayBuffer and keeps painting while the worker does the work.

Prerequisites

Before implementing the pattern below, confirm each of these:

  • A measured baseline. A performance.now() reading (or a DevTools Performance recording) showing that parsing exceeds 4–5 ms on your slowest target device. Below that threshold, worker round-trip overhead makes things worse, not better.
  • Module worker support in your bundler. new Worker(new URL('./parse.worker.ts', import.meta.url), { type: 'module' }) is understood natively by Vite, webpack 5, Rollup, and esbuild.
  • Familiarity with the message contractpostMessage, onmessage, and the transfer list. See Message Passing Strategies if the request/response correlation pattern is new to you.
  • TextEncoder / TextDecoder available in both contexts (every browser since 2017; they exist in WorkerGlobalScope too).
  • COOP/COEP headers only if you intend to use SharedArrayBuffer. The transferable ArrayBuffer path used throughout this guide needs no special headers.
  • A stable payload schema, or a validator you are willing to run inside the worker. Validation is one of the cheapest things to move across the boundary, because it produces a boolean rather than a large object graph.

Step-by-Step Implementation

1. Measure the baseline before changing anything

Instrument the synchronous path first. Without a number, you cannot tell whether the worker helped, and a worker that helps on a 12-core desktop can regress on a mid-range phone.

// measure-baseline.ts
export function measureParse(jsonText: string, runs = 20): number {
  // Warm up so the JIT has tiered up before the timed loop.
  for (let i = 0; i < 3; i++) JSON.parse(jsonText);

  const t0 = performance.now();
  for (let i = 0; i < runs; i++) JSON.parse(jsonText);
  const perParse = (performance.now() - t0) / runs;

  console.log(`JSON.parse: ${perParse.toFixed(2)} ms per call, ` +
    `${(jsonText.length / 1_048_576).toFixed(1)} MB payload`);
  return perParse;
}
Trade-off

A microbenchmark loop reuses a hot string and a warm heap, so it flatters JSON.parse by 10–25% compared with a cold, once-per-page parse of a freshly fetched response. Treat the loop result as a floor, and confirm against a real DevTools recording — the method is spelled out in Benchmarking JSON.parse vs Worker Deserialization.

2. Stand up a dedicated parsing worker

Give parsing its own long-lived worker rather than spawning one per request. Worker start-up costs 5–15 ms (module resolution, script evaluation, realm creation), which would dominate the very cost you are trying to remove.

// parse-client.ts — typed request/response contract
export type ParseRequest = {
  id: string;
  format: 'json' | 'ndjson' | 'binary';
  buffer: ArrayBuffer;      // always bytes, never a string
};

export type ParseResponse =
  | { id: string; status: 'complete'; result: unknown; parseMs: number }
  | { id: string; status: 'error'; name: string; message: string };

const worker = new Worker(new URL('./parse.worker.js', import.meta.url), { type: 'module' });
const pending = new Map<string, { resolve: (r: unknown) => void; reject: (e: Error) => void }>();

worker.onmessage = ({ data }: MessageEvent<ParseResponse>) => {
  const entry = pending.get(data.id);
  if (!entry) return;                       // late reply after a timeout — drop it
  pending.delete(data.id);
  if (data.status === 'complete') entry.resolve(data.result);
  else entry.reject(Object.assign(new Error(data.message), { name: data.name }));
};

export function parseInWorker(buffer: ArrayBuffer, format: ParseRequest['format']) {
  const id = crypto.randomUUID();
  return new Promise<unknown>((resolve, reject) => {
    pending.set(id, { resolve, reject });
    // The buffer is listed as transferable: ownership moves, nothing is copied.
    worker.postMessage({ id, format, buffer } satisfies ParseRequest, [buffer]);
  });
}
Trade-off

One shared worker keeps start-up cost at zero but serialises requests: a 200 ms parse delays every queued request behind it. If you routinely parse several independent payloads at once, put this client in front of a pool sized to navigator.hardwareConcurrency — see Worker Pool Management for the queueing and sizing heuristics.

3. Move bytes, not strings

The single highest-leverage decision on this page: never send a JavaScript string over postMessage when the payload is large. Strings are structured-cloned, which copies every UTF-16 code unit on the sending side and allocates a fresh string on the receiving side. ArrayBuffer can be transferred instead — the receiving realm takes ownership of the same memory and the send costs a pointer hand-off.

Where possible, skip the string entirely: Response.arrayBuffer() gives you the raw network bytes without ever materialising a JS string on the main thread.

// fetch-as-bytes.ts
export async function fetchAndParse(url: string): Promise<unknown> {
  const response = await fetch(url);
  // arrayBuffer() never creates a JS string — no UTF-8 → UTF-16 conversion here.
  const buffer = await response.arrayBuffer();
  return parseInWorker(buffer, 'json');       // transferred, zero-copy
}

// If you already hold a string (e.g. from localStorage), encode it once:
export function encodeForTransfer(jsonText: string): ArrayBuffer {
  const bytes = new TextEncoder().encode(jsonText);   // UTF-16 → UTF-8, one pass
  return bytes.buffer;                                 // detachable, transferable
}
Performance

Transferring a 5 MB ArrayBuffer via the transfer list completes in under 1 ms and is independent of size. Structured-cloning the equivalent 5 MB string copies every byte and blocks the main thread for 8–12 ms. TextEncoder.encode on that string costs a further 4–7 ms — which is why fetching straight to an ArrayBuffer beats encoding a string you already decoded.

4. Decode, parse, and validate inside the worker

The worker owns the whole tail of the pipeline: decode bytes → parse → validate → reshape. Validation and reshaping matter as much as the parse, because they decide how big the reply will be.

// parse.worker.js
const decoder = new TextDecoder('utf-8');

self.onmessage = ({ data }) => {
  const { id, format, buffer } = data;
  const t0 = performance.now();
  try {
    const text = decoder.decode(buffer);          // buffer is owned by this realm now
    const parsed = format === 'ndjson'
      ? text.split('\n').filter(Boolean).map((line) => JSON.parse(line))
      : JSON.parse(text);

    const problems = validate(parsed);
    if (problems.length) {
      throw Object.assign(new Error(problems[0]), { name: 'SchemaError' });
    }

    // Reshape to the narrow view model the UI actually renders.
    const result = summarise(parsed);
    self.postMessage({ id, status: 'complete', result, parseMs: performance.now() - t0 });
  } catch (err) {
    self.postMessage({ id, status: 'error', name: err.name, message: err.message });
  }
};

function validate(rows) {
  const problems = [];
  if (!Array.isArray(rows)) return ['expected a top-level array'];
  for (let i = 0; i < rows.length; i++) {
    const r = rows[i];
    if (typeof r?.id !== 'number') problems.push(`row ${i}: missing numeric id`);
    if (problems.length > 5) break;               // fail fast, do not build a huge report
  }
  return problems;
}

function summarise(rows) {
  // Return aggregates plus a first page — not 120,000 objects.
  let total = 0;
  for (const r of rows) total += r.value ?? 0;
  return { count: rows.length, total, page: rows.slice(0, 100) };
}
Trade-off

Returning the full parsed graph undoes the win: structured-cloning 120,000 small objects costs 60–140 ms on the main thread, which is the same order as the parse you just moved. Return aggregates, a paged slice, or columnar typed arrays you can transfer. If the UI genuinely needs every row, hand it back in batches so each clone stays under a frame.

5. Route adaptively by payload size

Worker offload has a fixed cost — roughly 0.5–2 ms of round-trip latency plus the reply clone — so it is a loss on small payloads. Measure the crossover on your own data and encode it as a threshold; on typical desktop hardware it lands between 1 and 2 MB.

// adaptive-route.ts
const WORKER_THRESHOLD_BYTES = 1.5 * 1024 * 1024;   // measured, not guessed

export async function parseAdaptive(buffer: ArrayBuffer): Promise<unknown> {
  if (buffer.byteLength < WORKER_THRESHOLD_BYTES) {
    // Small enough that a round-trip would cost more than the parse.
    return JSON.parse(new TextDecoder().decode(buffer));
  }
  return parseInWorker(buffer, 'json');
}
Adaptive routing decision tree for a received ArrayBuffer A received ArrayBuffer hits a single test: is byteLength below the measured 1.5 megabyte crossover? If yes, the main thread parses in place because a round trip would cost more than the parse. If no, the buffer is posted in the transfer list to the worker, which decodes, parses and validates it and clones a narrow reply back. Both branches converge on rendering the same view model. ArrayBuffer received byteLength < 1.5 MB? measured crossover Yes Main thread JSON.parse(text) No Worker thread postMessage(buf, [buf]) — zero-copy TextDecoder → JSON.parse → validate parsed in place narrow reply, cloned Render the view model
Adaptive routing: below the measured 1.5 MB crossover the round trip costs more than the parse, so the main thread does it in place; above it the buffer is transferred and the worker returns only the narrow shape the UI renders. Both branches end at the same view model.
Trade-off

A fixed byte threshold is a proxy for parse cost, and a poor one when payload shape varies: 1 MB of deeply nested objects parses far slower than 1 MB of flat numbers. If your payloads differ structurally, record the observed parseMs per endpoint and route by endpoint rather than by byte length.

6. Compress payloads that are large or network-bound

When bytes travel over the network, or get persisted in IndexedDB, CompressionStream shrinks JSON dramatically — text compresses well because of repeated key names. Compress on the producing side, decompress inside the worker so the CPU cost stays off the render thread.

// compress-and-send.ts
export async function compressAndSend(jsonText: string, worker: Worker): Promise<void> {
  const input = new Blob([jsonText]).stream();
  const compressed = input.pipeThrough(new CompressionStream('gzip'));
  const buffer = await new Response(compressed).arrayBuffer();
  worker.postMessage({ kind: 'gzip-json', buffer }, [buffer]);   // ownership moves
}
// parse.worker.js — decompression branch
async function decodeGzipJson(buffer) {
  const stream = new Response(buffer).body.pipeThrough(new DecompressionStream('gzip'));
  const text = await new Response(stream).text();
  return JSON.parse(text);
}
Trade-off

gzip typically cuts JSON by 60–80%, at roughly 2–5 ms of CPU per MB each way. That pays for itself over a network link or for stored blobs, but it is pure overhead for an in-memory hand-off between two threads on the same machine — transfer is already zero-copy there. Note also that CompressionStream supports gzip, deflate, and deflate-raw only; brotli is not available in any current browser.

7. Offload tokenization and heavy regular expressions

Log parsing, CSV field splitting, and format sniffing are regex-heavy, and a pattern with nested quantifiers can backtrack catastrophically — turning a 20 ms scan into a multi-second freeze. In a worker, that worst case is survivable: you can terminate the thread.

// tokenize.worker.js
// Compile once at module scope so V8 reuses the compiled regex across messages.
const PATTERNS = {
  'log-entry': /^(?<timestamp>\d{4}-\d{2}-\d{2}T[\d:.Z]+)\s+(?<level>\w+)\s+(?<message>.*)$/gm,
};

self.onmessage = ({ data }) => {
  const { text, pattern } = data;
  const regex = PATTERNS[pattern];
  if (!regex) return self.postMessage({ error: `unknown pattern: ${pattern}` });

  regex.lastIndex = 0;                 // /g regexes are stateful — always reset
  const matches = [];
  let match;
  while ((match = regex.exec(text)) !== null) {
    matches.push(match.groups);
    if (match.index === regex.lastIndex) regex.lastIndex++;   // guard zero-length matches
    if (matches.length % 10_000 === 0) self.postMessage({ progress: matches.length });
  }
  self.postMessage({ matches, count: matches.length });
};
// tokenize-client.ts — bound the worst case with a watchdog
const tokenizer = new Worker(new URL('./tokenize.worker.js', import.meta.url), { type: 'module' });

export function tokenize(text: string, pattern: string, budgetMs = 5000) {
  return new Promise<Array<Record<string, string>>>((resolve, reject) => {
    const watchdog = setTimeout(() => {
      tokenizer.terminate();           // kills the backtracking loop dead
      reject(new Error(`tokenize exceeded ${budgetMs} ms budget`));
    }, budgetMs);

    tokenizer.onmessage = ({ data }) => {
      if (data.progress !== undefined) return;    // progress ping, keep waiting
      clearTimeout(watchdog);
      data.error ? reject(new Error(data.error)) : resolve(data.matches);
    };
  });
}
Trade-off

terminate() is the only way to stop a runaway regex, and it is unconditional: in-flight state in that worker is lost and you must construct a replacement before the next request. Budget for the 5–15 ms respawn, and keep tokenization in its own worker so terminating it never destroys a warm JSON parser.

8. Deserialize binary formats with DataView

Binary containers — protocol frames, custom telemetry blobs, media headers — skip text decoding entirely. DataView reads fixed offsets with explicit endianness, so the worker never allocates an intermediate string.

// binary.worker.js
const MAGIC = 0xDEADBEEF;

self.onmessage = ({ data }) => {
  const { buffer } = data;
  const view = new DataView(buffer);

  if (view.byteLength < 12) {
    return self.postMessage({ error: 'truncated header' });
  }
  const magic = view.getUint32(0, /* littleEndian */ true);
  if (magic !== MAGIC) {
    return self.postMessage({ error: `bad signature 0x${magic.toString(16)}` });
  }

  const headerSize = view.getUint32(4, true);
  const recordCount = view.getUint32(8, true);

  // Read the payload as a typed array over the SAME memory — no copy.
  const records = new Float32Array(buffer, headerSize, recordCount);
  const stats = { min: Infinity, max: -Infinity };
  for (const v of records) {
    if (v < stats.min) stats.min = v;
    if (v > stats.max) stats.max = v;
  }
  self.postMessage({ headerSize, recordCount, stats });
};
Trade-off

Binary parsing removes both the UTF-8 decode and the object-graph allocation, so it is 5–20× faster than the JSON path — but you own endianness, alignment, and bounds checking. A Float32Array view requires its byte offset to be a multiple of 4, or the constructor throws a RangeError; pad your header to a 4-byte boundary if you control the format. The same offset arithmetic underpins the pixel buffers in Image Processing in Workers.

Data-Transfer Strategy: Clone, Transfer, or Share

Every design decision on this page reduces to one question: how do the bytes cross the thread boundary? There are three mechanisms, and for parsing workloads they are not interchangeable.

Mechanism Cost profile Right for parsing when
Structured clone (default postMessage) O(N) copy on the sender, O(N) allocation on the receiver; ~8–12 ms per 5 MB string The payload is small (<100 KB), or the value is the reply and you have already reduced it to aggregates
Transferable ArrayBuffer O(1) pointer hand-off, <1 ms regardless of size; source buffer is detached Almost always, for the request. This is the default recommendation of this guide
SharedArrayBuffer + Atomics No copy, no detach, concurrent access; requires cross-origin isolation Multiple workers must read the same source bytes at once, or you are streaming through a ring buffer

For the overwhelming majority of parsing pipelines, a transferred ArrayBuffer is correct: the main thread has no further use for the raw bytes after handing them off, and detachment enforces that at runtime instead of leaving a stale copy around. Reach for shared memory only when several workers must fan out over the same buffer — the coordination rules are covered in SharedArrayBuffer & Atomics, and the decision itself in postMessage vs SharedArrayBuffer: When to Choose Each.

The reply direction deserves separate thought. A transfer only works for binary-ish results, so a columnar shape often beats a row-oriented one: three Float64Arrays of 120,000 elements transfer in under a millisecond, whereas 120,000 { id, value, label } objects must be cloned. When the full row set is unavoidable, stream it — Streaming JSON Parsing with Transferable Chunks splits a multi-megabyte document into sequential ArrayBuffer slices so partial results render while the tail is still parsing, and CSV & JSON Transform Pipelines applies the same batching discipline to tabular sources.

Structured clone, transferable ArrayBuffer and SharedArrayBuffer compared as memory diagrams Three panels, each showing a main-thread heap above a worker heap for the same 5 megabyte payload. Structured clone copies byte for byte, costs 8 to 12 milliseconds and leaves 10 megabytes resident. A transferable ArrayBuffer hands ownership over in under a millisecond, detaching the sender's buffer and leaving 5 megabytes resident. SharedArrayBuffer maps one 5 megabyte region into both threads with no copy and no detach, but requires cross-origin isolation via COOP and COEP. Structured clone Main thread heap — 5 MB source O(N) byte-for-byte copy 8–12 ms, main thread blocked Worker heap — 5 MB duplicate 10 MB resident while both live only for small payloads or replies Transferable ArrayBuffer Main thread heap — detached O(1) ownership hand-off under 1 ms at any size Worker heap — sole owner 5 MB resident, nothing copied default choice for parse requests SharedArrayBuffer Main thread — mapped view one region, two views no copy, no detach Worker — mapped view 5 MB resident, read concurrently requires COOP + COEP isolation
The same 5 MB payload under each mechanism. Cloning pays O(N) twice — time and memory; transferring is an O(1) pointer move that detaches the sender's buffer; sharing keeps one region visible to both threads but only inside a cross-origin isolated document.
COOP / COEP required for SharedArrayBuffer

SharedArrayBuffer exists only in a cross-origin isolated document. Serve Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp on the document response, and expect every cross-origin subresource to need CORP/CORS headers of its own. Without isolation, typeof SharedArrayBuffer is "undefined" in the page and in its workers — feature-detect and fall back to a transferable ArrayBuffer rather than letting the parser throw. Diagnostic steps are in Debugging SharedArrayBuffer Cross-Origin Errors.

Verification & Measurement

Offloading is only a win if you can prove the main thread got quieter. Measure three separate numbers — they answer different questions.

// verify.ts
export async function verifyOffload(buffer: ArrayBuffer) {
  // 1. Main-thread occupancy: how long did WE block?
  const sendStart = performance.now();
  const promise = parseInWorker(buffer, 'json');
  const mainThreadCost = performance.now() - sendStart;   // target: <1 ms

  // 2. End-to-end latency: what does the user wait for?
  const result = await promise;
  const roundTrip = performance.now() - sendStart;        // target: <10 ms per 5 MB

  console.table({ mainThreadCost, roundTrip });
  return result;
}

// 3. Frame health: did anything still block long enough to drop a frame?
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.warn(`Long task ${entry.duration.toFixed(0)} ms`, entry.attribution);
  }
}).observe({ type: 'longtask', buffered: true });

mainThreadCost is the number that must collapse — if it is still tens of milliseconds you are cloning a string somewhere instead of transferring bytes. roundTrip will be larger than the old synchronous parse on small payloads; that is expected and is exactly what the size threshold in step 5 guards against. The Long Task observer catches the case where the reply clone quietly reintroduces the jank you removed on the request side.

In DevTools, record a Performance profile and confirm the parse frames now appear under the worker’s own track rather than “Main”. Chrome shows each worker as a separate lane with its own flame chart; the walkthrough lives in Profiling Worker CPU Usage with the Chrome Performance Tab. To isolate serialization cost specifically, time the postMessage call in isolation as described in Measuring Structured Clone Cost with performance.now().

Correctness needs a check too: parsing in another realm must produce an identical object graph. A cheap regression test is to parse the same fixture both ways and compare canonical serializations — JSON.stringify(mainResult) === JSON.stringify(workerResult) catches key-order and numeric-precision surprises, particularly around integers beyond Number.MAX_SAFE_INTEGER that silently lose precision on both paths.

Where the three verification numbers appear in a performance recording A two-lane performance recording. The Main lane shows a narrow postMessage tick, then idle time across successive 16.7 millisecond frames, then a short block that applies the result. The parse.worker.js lane shows one long decode, parse, validate and summarise block running in parallel. Three annotations mark the measured values: main-thread cost of about 0.6 milliseconds at the postMessage tick, round-trip latency of about 9.4 milliseconds spanning postMessage to onmessage, and a PerformanceObserver longtask check reporting no entry on Main at or above 50 milliseconds. Recording after the offload 1 mainThreadCost ≈ 0.6 ms Main your page idle — 16.7 ms frames keep painting apply result transfer buf (zero-copy) reply: aggregates, clone 0.4 ms Worker parse.worker.js decode → JSON.parse → validate → summarise 2 roundTrip ≈ 9.4 ms — postMessage to onmessage 3 PerformanceObserver('longtask'): no entry on Main ≥ 50 ms
The three numbers answer different questions: mainThreadCost must collapse to well under a millisecond, roundTrip is what the user actually waits for, and the Long Task observer catches a reply clone quietly reintroducing the jank you removed on the request side.

Failure Modes & Error Handling

A parsing worker fails in ways a synchronous parse cannot, and the default behaviour of each is silence.

Malformed input. JSON.parse throws a SyntaxError inside the worker. Because Error objects do not survive structured clone with their prototype and stack intact, serialize the fields you need by hand — name, message, and a truncated stack — as shown in step 4. The full pattern, including cause chains and custom error classes, is in Structured Error Serialization Across Threads.

Uncaught exceptions and rejections. Anything thrown outside your try block, or inside an unawaited promise, never reaches the caller. Wire both handlers in the worker and an onerror on the client:

// parse.worker.js — catch what the message handler misses
self.addEventListener('error', (e) => {
  self.postMessage({ status: 'fatal', name: 'WorkerError', message: e.message,
                     at: `${e.filename}:${e.lineno}:${e.colno}` });
});
self.addEventListener('unhandledrejection', (e) => {
  self.postMessage({ status: 'fatal', name: 'UnhandledRejection', message: String(e.reason) });
  e.preventDefault();
});
// parse-client.ts — never let a dead worker strand pending promises
worker.onerror = (e) => {
  for (const [, entry] of pending) entry.reject(new Error(`worker crashed: ${e.message}`));
  pending.clear();
};

// Fired when a message ARRIVES but cannot be deserialized in this realm.
worker.onmessageerror = () => {
  for (const [, entry] of pending) entry.reject(new Error('reply failed structured clone'));
  pending.clear();
};

Detached buffer reads. After transferring a buffer, the sender’s reference has byteLength === 0 and any typed-array view over it throws TypeError: Cannot perform Construct on a detached ArrayBuffer. If the main thread needs the raw bytes afterwards — for a retry, or to hash them — slice a copy before posting, and accept that copy’s cost knowingly.

Non-cloneable replies. Functions, DOM nodes, Proxy objects, and class instances with private fields fail structured clone and surface as a DataCloneError at postMessage time. Return plain data only; if your validator produces a rich object, flatten it before posting.

Unbounded work. A pathological regex or a 500 MB payload can pin a worker indefinitely. Pair every request with a watchdog timer as in step 7, and respawn on timeout. A worker that is terminated mid-parse leaves no partial state to clean up — the whole realm goes away — which is precisely why the retry path is simple: construct a new Worker and re-post the request from a preserved copy of the input.

Back-pressure. If requests arrive faster than the worker drains them, the message queue grows without bound and memory follows. Cap the queue depth in the client and reject (or coalesce) beyond it; the general treatment is in Error Handling & Crash Recovery.

Lifecycle of a parse request and the four ways it fails A parse request moves down the happy path from Queued to Posted to Parsing to Complete. Four failure edges branch to the right: no reply within the watchdog budget leads to a timeout that terminates and respawns the worker and rejects the promise; a throw inside parse or validation is caught by the worker try-catch and posted back as a serialized error object; an uncaught throw or out-of-memory kills the worker and worker.onerror rejects every pending entry; and a reply that is not cloneable raises DataCloneError at postMessage time. The timeout path loops back to Queued for a retry from a preserved copy of the input. Queued id → {resolve, reject} postMessage(buf, [buf]) — buffer detached Posted watchdog armed worker onmessage — decode + parse Parsing worker thread busy clearTimeout → resolve(result) Complete aggregates cloned back no reply in 5 s parse or validate throws uncaught throw / OOM reply not cloneable Timeout (watchdog) terminate() the worker, reject the pending promise SyntaxError / SchemaError worker try/catch → { status: 'error', name, message } Worker crash worker.onerror → reject every pending entry, clear map DataCloneError thrown at postMessage — return plain data only respawn + re-post from a preserved copy
Each failure has exactly one handler that can see it: the client watchdog for a hung parse, the worker's own try/catch for bad input, worker.onerror for a dead realm, and the postMessage call site for a non-cloneable reply. Only the timeout path has a retry, because a terminated worker leaves no partial state behind.

Browser Compatibility

API Chrome Firefox Safari Edge
Web Workers 4 3.5 4 12
Module workers ({ type: 'module' }) 80 114 15 80
TextEncoder / TextDecoder 38 19 10.1 79
Transferable ArrayBuffer 17 18 6 12
CompressionStream / DecompressionStream (gzip, deflate) 80 113 16.4 80
SharedArrayBuffer (cross-origin isolated) 68 79 15.2 79
PerformanceObserver (longtask) 58 79

Two practical gaps: module workers only reached Firefox in version 114 (May 2023), so if you support older Firefox builds, bundle the worker as a classic script with importScripts or let your bundler emit an IIFE build. And the longtask entry type remains Chromium-only — on Firefox and Safari, fall back to requestAnimationFrame delta timing to detect dropped frames during verification.


Going Further

Format choice is the question that usually follows, and it is often asked in the wrong place. Choosing Between JSON, MessagePack and Protobuf in Workers measures all four stages — network, decode, allocation and the thread hop — and shows why changing the decoded shape moves an order of magnitude more time than changing the wire format.

Frequently Asked Questions

Why is JSON.parse slow on the main thread for large payloads?
V8 parses JSON synchronously on the calling thread. A 5 MB JSON payload with a deeply nested object graph can block the main thread for 15–40 ms, dropping frames and delaying input. Transferring it as a Uint8Array to a worker and parsing there keeps the UI thread free throughout.
How do I transfer a large JSON string zero-copy to a worker?
Convert the string with new TextEncoder().encode(jsonString) to get a Uint8Array. Pass uint8Array.buffer in the postMessage transfer list. In the worker, new TextDecoder().decode(buffer) reconstructs the string before JSON.parse. Total copy overhead drops from O(N) to O(0) bytes on the main thread.
When is payload compression worth the overhead?
Compress when your raw payload exceeds 2 MB and is sent over a network or stored. The CompressionStream API with gzip typically reduces JSON by 60–80%, but the encode/decode CPU cost is roughly 2–5 ms per MB. For sub-2 MB payloads the compression overhead exceeds the transfer savings.
How should I handle catastrophic regex backtracking in a worker?
Set a safety timeout: setTimeout(() => { worker.terminate(); }, 5000) before postMessage. Clear it in onmessage. This bounds worst-case latency and prevents a frozen worker from holding a MessagePort indefinitely. In the worker, compile regexes once outside the message handler to leverage V8 regex caching.
Does parsing in a worker make the result cheaper to hand back?
No — the parsed object graph still crosses the boundary by structured clone, and cloning 200,000 small objects can cost more than the parse did. Return the narrowest shape you actually render (aggregates, a paged slice, or columnar typed arrays you can transfer), not the whole tree.

See also