Benchmarking JSON.parse vs Worker Deserialization
Moving JSON.parse off the main thread only pays off above a payload size you have to measure, because the worker path pays a full string copy before it parses anything — and most published crossover numbers are measuring the wrong quantity.
This page builds the harness that gets the number right for your data. It is part of Data Parsing & Serialization, the parent guide for turning payloads into usable objects without stalling the UI, which sits under the wider High-Performance Computation Patterns reference. The measurement technique here is the payload-specific application of Measuring Structured Clone Cost with performance.now(); if you conclude the answer is “neither — stream it”, the sibling page Streaming JSON Parsing with Transferable Chunks covers that path.
The Four Numbers a Fair Benchmark Must Separate
A single stopwatch around each strategy produces a number that answers no useful question. The worker path is four distinct phases, and only two of them are main-thread time:
| Phase | Runs on | Blocks the UI? | What it costs |
|---|---|---|---|
postMessage serialization |
Main thread | Yes | A copy of the JSON string into the message payload |
| Inbound deserialization | Worker | No | Reconstructing the string in the worker heap |
JSON.parse |
Worker | No | The actual parse — the work you wanted to move |
| Return trip | Both | Yes, partly | Structured clone of whatever the worker sends back |
The main-thread strategy has exactly one phase, and it blocks for all of it. So the honest comparison is not “which is faster” but two separate questions: which produces a result sooner (wall clock), and which spends less time blocking the main thread. The answers frequently disagree, and the second one is why anyone reaches for a worker in the first place.
Minimal Reproducible Example
The harness below is the smallest complete thing that measures all four phases. It uses a module worker, keeps a reference to every parsed result so V8 cannot eliminate the work, and never sends the parsed object graph back.
Sample type reports is a subtraction between two points on this diagram.// bench.ts — main thread
interface ParseRequest { type: 'parse'; json: string }
interface ParseReply {
type: 'done';
parseMs: number; // JSON.parse inside the worker
receivedAbs: number; // absolute time the worker entered onmessage
postedAbs: number; // absolute time the worker called postMessage
summary: { records: number; checksum: number };
}
export interface Sample {
strategy: 'main' | 'worker';
blockingMs: number; // main-thread time the user pays for
wallMs: number; // dispatch to result
}
// Prevents V8 from optimising the parse away as dead code.
let sink: unknown = null;
const worker = new Worker(new URL('./parse.worker.ts', import.meta.url), {
type: 'module',
});
function runMainThread(json: string): Sample {
const t0 = performance.now();
sink = JSON.parse(json);
const t1 = performance.now();
return { strategy: 'main', blockingMs: t1 - t0, wallMs: t1 - t0 };
}
function runWorker(json: string): Promise<Sample> {
return new Promise((resolve, reject) => {
let serializeMs = 0; // filled in below, read inside the handler
let dispatchAbs = 0;
const onMessage = (e: MessageEvent<ParseReply>) => {
const receiveAbs = performance.timeOrigin + performance.now();
worker.removeEventListener('message', onMessage);
// Return-trip clone happens BEFORE this handler runs, so it is
// measured as the gap between the worker's post and our receive.
const returnMs = receiveAbs - e.data.postedAbs;
sink = e.data.summary;
resolve({
strategy: 'worker',
blockingMs: serializeMs + returnMs,
wallMs: receiveAbs - dispatchAbs,
});
};
worker.addEventListener('message', onMessage);
worker.addEventListener('error', reject, { once: true });
const dispatchStart = performance.now();
dispatchAbs = performance.timeOrigin + dispatchStart;
const request: ParseRequest = { type: 'parse', json };
worker.postMessage(request); // synchronous serialization
serializeMs = performance.now() - dispatchStart;
});
}
export async function compare(json: string, iterations = 25): Promise<Sample[]> {
const samples: Sample[] = [];
for (let i = 0; i < iterations; i++) {
samples.push(runMainThread(json));
samples.push(await runWorker(json));
}
return samples.slice(10); // discard 5 warm-up pairs
}
export function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b);
const mid = sorted.length >> 1;
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
// parse.worker.ts — deserialize off the main thread, reply small
interface ParseRequest { type: 'parse'; json: string }
function summarise(data: unknown): { records: number; checksum: number } {
const rows = Array.isArray(data) ? data : [data];
let checksum = 0;
for (const row of rows) checksum += Object.keys(row as object).length;
return { records: rows.length, checksum };
}
self.onmessage = (e: MessageEvent<ParseRequest>) => {
// Absolute timestamps: the worker's performance.now() starts from the
// worker's own timeOrigin, not the document's.
const receivedAbs = performance.timeOrigin + performance.now();
const t0 = performance.now();
const data = JSON.parse(e.data.json);
const parseMs = performance.now() - t0;
const summary = summarise(data); // never post the object graph back
self.postMessage({
type: 'done',
parseMs,
receivedAbs,
postedAbs: performance.timeOrigin + performance.now(),
summary,
});
};
Step-by-Step Walkthrough
timeOrigin normalisation and the middle two brackets measure the gap between the two clocks instead of the work you care about.const dispatchStart = performance.now();
worker.postMessage(request);
const serializeMs = performance.now() - dispatchStart;
postMessage serializes its argument synchronously on the calling thread before returning. That makes this two-line sandwich the entire main-thread cost of sending, and it is the number people usually forget to record. For a plain one-byte JSON string the serializer is close to a memcpy, so it scales linearly with payload bytes and is roughly an order of magnitude cheaper than parsing the same bytes. It is not free, and past a few megabytes it is a long task on its own.
const receivedAbs = performance.timeOrigin + performance.now();
A dedicated worker gets its own timeOrigin, set when the worker context is created — so a raw performance.now() value from the worker is offset from the main thread’s by however long the page had been alive. Adding performance.timeOrigin puts both threads on the same absolute epoch-based scale, which is what makes the cross-thread subtractions below meaningful. Skip this and your “transfer time” will be a large negative number, or a large positive one, depending on when the worker booted.
const returnMs = receiveAbs - e.data.postedAbs;
The reply’s structured clone is deserialized before your message listener is invoked, so there is no hook inside the handler that can time it. Measuring from the worker’s post to the main thread’s first observable instant captures dispatch, queueing and deserialization together. With the small summary object used here that total sits in the tens of microseconds; swap in the parsed array and it becomes the dominant cost of the entire run.
sink = JSON.parse(json);
Assigning to a module-scope sink keeps the result reachable. Without it, an optimising compiler is entitled to notice the value is unused and skip work, and you end up benchmarking an empty loop. The same trick applies to the worker’s summarise call, which additionally forces the parsed graph to be walked at least once — a parse whose output is never touched can hide lazy-materialisation costs that your real code would pay later.
return samples.slice(10);
The first few iterations measure worker startup, cold JSON.parse code, and V8 climbing its optimisation tiers rather than steady-state throughput. Discarding five pairs is the minimum; for payloads under 1 MB, where a single iteration is only a few milliseconds, discard more.
Isolating the Measurement in DevTools
Numbers taken from a page that is also fetching, rendering and collecting garbage are noise. Stabilise the environment before you trust a single sample:
- Open Performance in Chrome DevTools, tick Disable cache, and set CPU throttling to 4× to approximate a mid-tier Android device. Record the whole harness run so long tasks line up against your marks.
- Emit user-timing marks around each phase (
performance.measure('mt-parse', { start, end })) so the phases appear on the Timings track instead of having to be reconstructed from console output. - Force a collection between strategies using the Collect garbage button in the Memory panel.
globalThis.gc()only exists when Chrome is launched with--js-flags="--expose-gc"; never ship code that depends on it. - Keep a
requestAnimationFrameloop running that paints something non-trivial — a canvas redraw is ideal. Main-thread blocking is invisible on an idle page; the frame drops it causes are the whole point of the exercise. - Repeat the run with the Worker track expanded so you can confirm the parse really executed off-thread and was not, for example, blocked behind worker startup. Worker frames are only visible under this track — see Chrome DevTools Worker Debugging if the track is empty.
Where the Crossover Lands
Measured on Chrome 124, an M-class laptop with no CPU throttling, against flat arrays of small objects. Treat these as shape, not as constants — apply the 4× throttle and every figure roughly quadruples:
| Payload | Main thread: blocking | Worker: blocking (serialize + return) | Worker: wall clock | What it means |
|---|---|---|---|---|
| 512 KB | ~4 ms | ~0.4 ms | ~9 ms | Fits a frame either way; not worth a worker |
| 2 MB | ~14 ms | ~1.5 ms | ~30 ms | Main thread eats most of a frame; worker keeps it clear |
| 8 MB | ~55 ms | ~6 ms | ~95 ms | Main thread drops 3–4 frames; worker path is clearly right |
Two patterns hold across every payload we have measured. End-to-end latency is always worse in the worker — typically 1.3–1.9× the direct parse, because the copy is pure additional work. Main-thread blocking is always better in the worker, and improves as a proportion as the payload grows, because serialization cost grows more slowly than parse cost. The decision is therefore about which resource is scarce: if the user is staring at a spinner and nothing else is animating, parse on the main thread and finish sooner. If anything is moving on screen, move it.
Heap behaviour follows the same asymmetry. A main-thread JSON.parse peaks at roughly 1.2× the final object size, the overhead being the source string plus transient buffers. The worker path peaks at 2.5–3× across the two heaps combined, because the string exists on both sides simultaneously and the message queue holds its own copy until deserialization completes. On memory-constrained devices that ratio, not the timing, is often what forces the decision.
The single biggest mistake in this benchmark is posting the parsed object back. Structured clone re-walks the entire graph on the way out and again on the way in, and for a 4 MB payload that return trip costs ~20 ms of main-thread time — more than the parse you just moved off it. Serialize results into a Float32Array or ArrayBuffer and pass it in the transfer list instead: the same trip drops below 0.5 ms.
Gotchas & Edge Cases
The two clocks do not share an origin. This is the defect that silently invalidates most homemade worker benchmarks. performance.now() inside a worker counts from that worker’s creation, not the document’s navigation, so subtracting a worker timestamp from a main-thread one yields the offset between the two origins plus the interval you wanted. Always normalise with performance.timeOrigin + performance.now() on both sides before comparing. The same discipline is covered in depth in Step-by-Step Guide to the Structured Clone Algorithm.
Timer resolution is clamped unless the page is cross-origin isolated. Chrome quantises performance.now() to 100 µs on ordinary pages and to 5 µs when the document is cross-origin isolated via Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. At the millisecond scale of a multi-megabyte parse this is irrelevant; when you start timing 512 KB payloads or individual return trips, quantised results that snap to suspiciously round steps are the clamp, not your code. Those are the same headers SharedArrayBuffer requires, so a cross-origin-isolated benchmark page gets both.
“Chunked JSON.parse with setTimeout” is not a thing. JSON.parse is atomic over a single document — there is no way to feed it half a string, yield to the event loop, and resume. Any strategy that claims to chunk a parse is really splitting the payload at the source: NDJSON with one record per line, a paginated API, or a character-level streaming parser. If your data cannot be split that way, the mid-range option does not exist and the choice really is main thread versus worker. If it can, Streaming JSON Parsing with Transferable Chunks usually beats both.
Worker startup is not free and must not land inside a sample. Creating a module worker and evaluating its script costs somewhere between 5 and 20 ms, and the first JSON.parse in a fresh context runs interpreted before it tiers up. The harness above reuses one worker across all iterations and discards the early pairs for exactly this reason. If your production code creates a worker per parse, benchmark that — include the constructor inside the timed region — because it changes the crossover point dramatically for small payloads.
Safari applies stricter clone limits on deep graphs. Very deeply nested object graphs can raise DataCloneError in Safari where Chrome copes. The fallback is to re-serialize inside the worker — postMessage(JSON.stringify(result)) — which adds roughly 10–15% and reintroduces a main-thread parse on receipt, so prefer flattening into a transferable typed array as described in Transferable Objects & Zero-Copy. Validate against Safari with your largest realistic payload before shipping either.
Performance Note
The rule of thumb that survives every payload shape we have measured: JSON.parse runs at roughly 100–300 MB/s in V8, and postMessage serialization of the equivalent string runs about an order of magnitude faster. Everything else follows from that ratio. Sending a payload to a worker costs you roughly a tenth of a parse in main-thread time and buys you the whole parse off-thread — so the worker wins on jank from around 1 MB, and the only way to lose that bet is to hand the parsed object graph back and pay the clone twice.
Two sanity checks before you commit to an architecture. First, confirm the parse is genuinely your bottleneck: if the payload took 400 ms to arrive over the network, a 30 ms parse is not what users are feeling, and Streaming JSON Parsing with Transferable Chunks will beat both options by overlapping the two. Second, re-run the harness with your real payload shape — deeply nested documents parse far slower per byte than flat arrays of numbers, which moves the crossover down by a factor of two or more. The number that matters is the one your data produces, not the one in the table above.