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.
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 contract —
postMessage,onmessage, and the transfer list. See Message Passing Strategies if the request/response correlation pattern is new to you. TextEncoder/TextDecoderavailable in both contexts (every browser since 2017; they exist inWorkerGlobalScopetoo).- COOP/COEP headers only if you intend to use
SharedArrayBuffer. The transferableArrayBufferpath 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;
}
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]);
});
}
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
}
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) };
}
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');
}
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);
}
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);
};
});
}
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 });
};
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.
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.
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.
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.