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:

The same 55 ms of parse work, charged to two different threads Two timelines drawn to the same scale for an 8 megabyte payload. Path A is a single 55 millisecond JSON.parse block on the main thread, which overruns three consecutive 16.7 millisecond frame deadlines. Path B splits the work: a 4 millisecond postMessage serialize block on the main thread, then a long idle stretch during which the UI keeps painting, while the worker thread deserializes the string, runs the same 55 millisecond JSON.parse and posts a small reply, followed by a 3 millisecond return-trip block back on the main thread. Path A costs the UI thread 55 milliseconds and finishes at 55 milliseconds; Path B costs it about 7 milliseconds and finishes at about 90 milliseconds. The same 55 ms of parse work, billed two different ways 8 MB payload · widths to scale PATH A · JSON.parse on the main thread Main thread the only thread JSON.parse 55 ms — the UI is frozen for every one of them result ready 55 ms wall clock 16.7 ms 16.7 ms 16.7 ms 16.7 ms 16.7 ms frame budget ▲ missed by Path A PATH B · parse inside a worker serialize 4 ms return trip 3 ms Main thread ≈ 7 ms of blocking in total idle — the UI keeps painting result ≈ 90 ms Worker thread costs the UI nothing deserialize JSON.parse the same 55 ms — off the main thread reply blocks the main thread runs on the worker idle — not this thread's cost Identical parse work. Path A charges all 55 ms to the UI thread; Path B charges 7 ms and finishes 35 ms later.
Neither number is "the" answer. Path A wins the wall clock, Path B wins the frame budget — and only one of those is visible to the user.
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.

How the harness is wired across the thread boundary Two panels either side of a dashed thread boundary. The left panel, bench.ts on the main thread, runs three steps: capture dispatchStart, call worker.postMessage which serializes synchronously and blocks, then compute serializeMs. The main thread is then free until step eight, when the onmessage handler records receiveAbs on a reply that has already been cloned in. The right panel, parse.worker.ts, runs four steps: record receivedAbs on the document's epoch, run JSON.parse to obtain parseMs, call summarise so the parsed graph is actually walked, and post back a two-number summary rather than the parsed array. An arrow crossing the boundary carries the JSON string out; a second arrow carries the summary object back. Where each measurement is taken thread boundary bench.ts main thread — this is UI time 1 · dispatchStart = performance.now() 2 · worker.postMessage(request) serializes synchronously — blocking 3 · serializeMs = now() − dispatchStart main thread free — the event loop runs 8 · onmessage → receiveAbs reply already cloned in — blocking parse.worker.ts worker thread — free of the frame budget 4 · receivedAbs = timeOrigin + now() lifted onto the document's epoch 5 · JSON.parse(e.data.json) parseMs — the work you moved off-thread 6 · summarise(data) walks the graph so nothing is elided 7 · postMessage(summary) two numbers — never the parsed array JSON string copied the summary object Only steps 2 and 8 run on the main thread; steps 4 to 7 are free. That asymmetry is the whole point of the harness.
Two files, eight steps, four timestamps. Everything the 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

Five timestamps, and the subtractions each pair yields The upper half shows two raw clocks: the main thread's performance.now starts at the document's time origin, while the worker's starts at its own, later origin, leaving a multi-second offset between them that makes any raw cross-thread subtraction meaningless. The lower half places five normalised timestamps on one absolute axis: dispatchStart and the return of postMessage on the main thread, receivedAbs and postedAbs in the worker, and receiveAbs back on the main thread. Brackets show that dispatchStart to postMessage-returns is serializeMs paid by the main thread, postMessage-returns to receivedAbs is delivery paid by nobody, receivedAbs to postedAbs is the parse and summarise work paid by the worker, postedAbs to receiveAbs is returnMs paid by the main thread, and the full span from dispatchStart to receiveAbs is wallMs. Two clocks, one axis 1 · The raw clocks do not share an origin main thread 0 = document timeOrigin worker 0 = worker timeOrigin (created later) 4.2 s of offset — subtract across it and the numbers are nonsense 2 · performance.timeOrigin + performance.now() puts both on one scale wallMs = receiveAbs − dispatchAbs serializeMs main thread pays delivery nobody pays parse + summarise the worker pays returnMs main thread pays time → dispatchStart main thread postMessage returns main thread receivedAbs worker postedAbs worker receiveAbs main thread Every field in Sample is one of these five points minus another — which only works once both threads report the same epoch.
Skip the 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:

  1. Open Performance in Chrome DevTools, tick Disable cache, and set CPU throttling to to approximate a mid-tier Android device. Record the whole harness run so long tasks line up against your marks.
  2. 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.
  3. 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.
  4. Keep a requestAnimationFrame loop 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.
  5. 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.
The same harness run, as it appears in the Performance panel A simplified Chrome DevTools Performance panel. The toolbar shows the record button, a ticked Disable cache checkbox and a CPU four times slowdown control, marked as callout one. Below a millisecond ruler, the Frames track shows three normal frames followed by one long frame; the Main track shows a fifty-eight millisecond task containing JSON.parse, flagged with a corner marker and annotated as callout two, and further along the same track sits idle. The Timings track shows two user-timing marks, mt-parse aligned with the main-thread task and wk-parse aligned with the later worker run. The Worker track for parse.worker.ts shows a fifty-five millisecond JSON.parse that costs the Main track nothing, marked as callout three. Reading the trace: what to set, and what to look for Elements Console Sources Network Performance Memory Record Disable cache CPU: 4× slowdown 1 Network: No throttling 0 ms 50 100 150 200 250 300 Frames 1 long frame Main renderer Task 58 ms JSON.parse long task — every frame inside is dropped 2 idle Timings mt-parse wk-parse Worker parse.worker.ts JSON.parse 55 ms no Main-track cost 3 1 Throttle the CPU 4× with the cache disabled — an unthrottled desktop number is not the one your users get. 2 The corner-flagged long task is the main-thread parse; every frame inside it is a dropped frame. 3 Expand the Worker track to prove the parse ran off-thread rather than behind worker start-up.
One recording, both strategies: the main-thread run at the left of the trace, the worker run at the right. The Frames track is the honest scoreboard.

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
Main-thread blocking time against payload size A chart with payload size on a logarithmic horizontal axis from 0.5 to 8 megabytes and main-thread blocking time in milliseconds on the vertical axis. The direct main-thread parse rises steeply from 4 milliseconds at 512 kilobytes, through 14 milliseconds at 2 megabytes, to 55 milliseconds at 8 megabytes, crossing the 16.7 millisecond frame budget just above 2 megabytes. The worker path, whose blocking time is only postMessage serialization plus the return trip, stays almost flat: 0.4 milliseconds, 1.5 milliseconds and 6 milliseconds at the same three sizes. A shaded band between 1 and roughly 2.2 megabytes marks the region where switching to a worker starts to pay for itself. Blocking time is where the two paths diverge main-thread blocking (ms) worth switching around here 0 20 40 60 16.7 ms — one frame at 60 Hz 4 ms 14 ms 0.4 ms 1.5 ms main thread 55 ms blocking worker 6 ms blocking 0.5 MB 1 MB 2 MB 4 MB 8 MB payload size (log scale) Both curves are main-thread time. End-to-end latency runs the other way: the worker path is always the slower of the two to finish.
The worker curve is flat because serialization scales with bytes while parsing scales with structure. That gap, not the wall clock, is what buys you frames.

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.

Return flat buffers, not objects

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

Five ways the numbers lie, and what each one means A decision tree branching from the symptom that the worker numbers do not make sense. Negative or implausibly large transfer times mean the two clocks are not aligned, so add performance.timeOrigin on both sides. A worker path that is no faster than the main thread means the parsed graph is being posted back, so reply with a summary or a transferable instead. First samples that are wildly slower than the rest mean worker start-up and JIT warm-up are inside the sample, so reuse one worker and discard the first five pairs. Timings that snap to suspiciously round steps mean the page is not cross-origin isolated, and COOP plus COEP unlock five microsecond resolution. A parse time near zero for a multi-megabyte payload means the result was optimised away, so keep a module-scope sink and touch the parsed graph. When the numbers look wrong, one of these five is why WHAT YOU SEE WHAT IT ACTUALLY IS — AND THE FIX The worker numbers don't make sense Transfer times come out negative or implausibly large The two clocks are not aligned add performance.timeOrigin on both sides The worker path is no faster than parsing on the main thread You posted the parsed graph back reply with a summary or a transferable The first few samples are wildly slower than the rest Worker boot and JIT are in the sample reuse one worker; discard the first five pairs Every timing snaps to a suspiciously round step The page is not cross-origin isolated COOP + COEP unlock 5 µs timer resolution Parse time is close to zero for a multi-megabyte payload The result was optimised away keep a module-scope sink and touch the graph Four of the five still produce a plausible-looking number — which is why you check them before you trust a crossover.
Work left to right. Every one of these has shipped in a published benchmark at some point, and only the first is obvious from the output.

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.

Main-thread cost of a 4 MB payload, by what the worker sends back Three horizontal bars measuring main-thread blocking time for the same 4 megabyte payload against a 16.7 millisecond frame budget. Parsing directly on the main thread blocks for 28 milliseconds, roughly 1.7 frames. Routing through a worker that replies with a transferred ArrayBuffer blocks for 3.4 milliseconds: 3.0 milliseconds of postMessage serialization plus a 0.4 millisecond return trip, comfortably inside one frame. Routing through a worker that posts the parsed object graph back blocks for 23 milliseconds, because the 20 millisecond return clone costs more than the parse that was moved off-thread. 4 MB payload: what the worker sends back decides everything 16.7 ms frame budget Direct main-thread parse the baseline ≈ 1.7 frames dropped 28 ms one JSON.parse, all of it on the main thread Worker, transferable reply the right way 3.4 ms serialize 3.0 ms + transferred ArrayBuffer reply 0.4 ms Worker, object graph back the mistake return clone of the parsed graph — 20 ms 23 ms serialize 3.0 ms + structured-clone the parsed graph back 20 ms Same payload, same parse, three reply shapes. Only the middle one keeps the frame budget.
The bottom row is the failure mode worth memorising: it moves the parse off the main thread and then hands back a bill larger than the one it saved.

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.

Frequently Asked Questions

At what payload size does worker deserialization outperform main-thread JSON.parse?
It depends which number you are optimising. For end-to-end latency, the main thread usually wins outright: the worker path pays a string copy on send plus the same parse work, so a result never arrives sooner. For main-thread blocking time — the number that governs dropped frames — the worker path pulls ahead at roughly 1 MB and is decisively better past 4–5 MB, provided the worker replies with a summary or a transferred ArrayBuffer rather than the parsed object graph. Run the harness on this page against your own payload shape; deeply nested objects shift the crossover down, flat numeric arrays shift it up.
Why does sending a JSON string to a worker cost more than just parsing it on the main thread?
Because you pay for the data twice. postMessage serializes the string synchronously on the calling thread — for a plain one-byte string that is close to a memcpy, but it is still main-thread time — and the worker then runs the same JSON.parse you were trying to avoid. Total CPU spent is strictly higher than the main-thread path. The reason to do it anyway is that the expensive half now runs off the main thread, so the frame budget is preserved even though the wall clock is longer.

See also