A Large JSON.parse Blocks the Main Thread and Spikes INP
This is a specific failure mode within offloading work to web workers with Comlink, itself part of the interactivity work in Core Web Vitals & Measurement: a single JSON.parse of a large payload runs synchronously on the main thread, blocks for hundreds of milliseconds, and pushes Interaction to Next Paint (INP) past 200ms for any click that overlaps it.
The shape is always the same. A fetch returns a multi-megabyte JSON response — a product catalog, a dashboard dataset, a list of map features — and the handler calls const data = JSON.parse(text) on the result. JSON.parse is synchronous and uninterruptible: a 4MB payload routinely costs 150–400ms of pure CPU on a mid-range phone, during which the main thread runs no event listeners and paints no frames. If the user clicks, scrolls, or types in that window, the interaction's input delay absorbs the entire parse and INP spikes. Because the metric reports the worst interaction across the visit, one such parse on initial load or on a "load more" action is enough to fail the field metric even when every other interaction is fast.
Rapid Diagnosis: Confirm the Parse Is the Stall
Before changing anything, confirm JSON.parse is the blocking function — not the fetch, not rendering the parsed result.
- Open the Performance panel, enable
4xCPU throttling, and record while triggering the data load. - Find the long task on the Main track that overlaps the interaction. Anything wider than
50msis a long task; a parse stall is usually150ms+. - Expand the flame chart under that task. A JSON parse stall shows a single wide
Parse/JSON.parseframe with no children — it is opaque native work, which is the giveaway that you cannot "optimize the loop," only move it. - Switch to the Interactions track and read the recorded INP for the click. Confirm its input delay or processing duration aligns with the parse width.
- Check the Network panel for the response size. A
> 1MBJSON body that is parsed synchronously is the confirmed culprit.
If the wide frame is JSON.parse itself, the fix is structural — you cannot make native parsing faster, only move it or shrink its input. The replay-and-rank workflow for nailing down which interaction is worst lives in profiling event handlers for INP.
For a faster field signal before you open DevTools, instrument the parse directly. Wrapping the parse in a performance.measure and shipping the slowest samples to your RUM endpoint tells you the real-device distribution, which is usually far worse than your laptop suggests:
function timedParse(text, label) {
const start = performance.now();
const data = JSON.parse(text);
const ms = performance.now() - start;
if (ms > 50) navigator.sendBeacon('/rum/parse', JSON.stringify({ label, ms, bytes: text.length }));
return data;
// trade-off: measuring every parse adds a performance.now() pair per call; gate the
// beacon behind a >50ms threshold so you only report parses that can actually hurt INP.
}
A field histogram that shows a heavy tail of 200ms+ parses on low-end devices confirms the problem is worth the worker overhead. A tail that tops out at 30ms means the parse is not your INP problem and you should profile elsewhere.
Root Cause Analysis: Four Failure Modes Behind a Parse Stall
1. Synchronous parse on the main thread (the core mechanism). JSON.parse blocks the single main thread for the full duration. There is no internal yielding; a 4MB string is parsed in one uninterruptible run, so every millisecond counts directly against any overlapping interaction's INP.
2. Over-fetching: parsing fields the UI never reads. The endpoint returns the full record shape — audit logs, nested relations, base64 thumbnails — but the view renders a handful of columns. Parse cost scales with total bytes and node count, so half the parse time is often spent building objects that are immediately discarded.
3. Parse-then-clone amplification. The parsed object is handed straight to a Web Worker, a state-management structuredClone, or JSON.parse(JSON.stringify(...)) for a deep copy. The original parse stall is then doubled by a clone of the same size on the same thread.
4. Parse on a hot interaction path. The parse runs inside a click or input handler (filtering a freshly fetched list, expanding a "load more" batch) rather than during idle time, so it lands directly in the interaction's processing duration instead of being hidden before the user acts.
The mode you are in dictates the fix. Mode 1 always points to a worker (Fix 1) because the parse is irreducibly synchronous. Mode 2 points to slimming (Fix 3) because you are parsing bytes nobody reads. Mode 3 is solved by removing the redundant clone — never deep-copy a freshly parsed object; treat it as immutable instead. Mode 4 is the subtlest: if the parse cannot move off-thread for some reason, at minimum move it off the interaction by parsing during idle time before the user clicks, so it does not land in processing duration. In practice most stalls are a blend of modes 1 and 2, and the two fixes compose — slim the payload to shrink the parse, then move what remains to a worker so even the smaller parse never touches the main thread during an interaction.
Step-by-Step Resolution
Fix 1 — Parse in a Comlink worker (highest impact)
Move the synchronous parse onto a Web Worker so the main thread stays free. With Comlink the call reads like a local async function.
// json.worker.js
import * as Comlink from 'comlink';
Comlink.expose({
parseAndSlim(text) {
const data = JSON.parse(text); // 300ms runs OFF the main thread now
// Reduce inside the worker so the return clone stays small.
return data.items.map(({ id, name, price }) => ({ id, name, price }));
// trade-off: returning the FULL parsed object would clone it back to the main
// thread and reintroduce a stall — always slim the result inside the worker.
},
});
// main.js
import * as Comlink from 'comlink';
const worker = new Worker(new URL('./json.worker.js', import.meta.url), { type: 'module' });
const api = Comlink.wrap(worker);
async function loadCatalog(url) {
const text = await fetch(url).then((r) => r.text()); // get text, not .json()
return api.parseAndSlim(text); // main thread never blocks on parse
// trade-off: the 4MB string is still structured-cloned INTO the worker (~20-40ms);
// worth it to move a 300ms parse off-thread, but pointless for payloads under ~50KB.
}
Expected outcome: removes the parse from the main thread entirely; the parse-driven long task drops from ~300ms to 0ms, reducing the overlapping interaction's INP by roughly the full parse width (commonly 200–300ms). The remaining cost is the argument clone into the worker. Note the deliberate r.text() rather than r.json() — calling .json() would parse on the main thread before you ever reach the worker, defeating the entire fix.
Fix 2 — Stream and parse in chunks during the fetch
For payloads that arrive as a stream, parse incrementally as bytes land instead of waiting for the whole body and parsing in one block. A streaming JSON parser turns one 300ms stall into many small chunks interleaved with the network.
import { parser } from 'stream-json';
import { streamArray } from 'stream-json/streamers/StreamArray';
async function streamItems(url, onItem) {
const res = await fetch(url);
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
const pipeline = parser().pipe(streamArray());
pipeline.on('data', ({ value }) => onItem(value)); // emit items as they parse
for (let r; !(r = await reader.read()).done; ) pipeline.write(r.value);
pipeline.end();
// trade-off: streaming parsers are slower per-byte than native JSON.parse and add a
// dependency; use this only when you can render incrementally or the body truly streams.
}
Expected outcome: the largest single task drops from the full parse width to one chunk's worth, typically < 30ms, and items render progressively. Best when the response is array-shaped and the UI can show rows as they arrive; weaker when the consumer needs the whole object before it can do anything.
Fix 3 — Slim the payload at the source (cheapest if you own the API)
The fastest parse is the one over fewer bytes. Push field selection to the server so the client parses only what it renders.
// Request only the fields the view binds to.
const url = '/api/catalog?fields=id,name,price&page=1&limit=200';
const items = await fetch(url).then((r) => r.json());
// trade-off: narrowing fields couples the client to a query contract and risks N+1
// follow-up requests when a detail view later needs the dropped fields — paginate too.
Expected outcome: parse time scales down roughly linearly with bytes removed; dropping a payload from 4MB to 800KB cuts the parse from ~300ms to ~60ms, often enough on its own to clear the 200ms boundary without a worker. Combine with pagination so no single response is large enough to stall. This is the right first move when the team controls the endpoint; reach for the worker (Fix 1) when you do not.
A related slimming lever is the wire format itself. Deeply nested objects with long, repeated key names parse slower than a flat columnar shape, because the parser builds far more object nodes. If you own the endpoint and the payload is tabular, returning parallel arrays of values plus a single header row — rather than an array of fully-keyed objects — can halve both the byte count and the node count, and therefore the parse time, before any worker is involved. Rehydrate to objects lazily on the client only for the rows actually rendered.
Verification: Before/After and CI
Confirm the win in the lab and the field, not by inspection.
// Scripted check (Playwright): assert no single task blocks during the data load.
const longTasks = await page.evaluate(() => new Promise((resolve) => {
const entries = [];
new PerformanceObserver((l) => entries.push(...l.getEntries().map((e) => e.duration)))
.observe({ type: 'longtask', buffered: true });
setTimeout(() => resolve(entries), 3000);
}));
expect(Math.max(0, ...longTasks)).toBeLessThan(50); // no long task during the load
// trade-off: longtask granularity is 50ms, so a 48ms residual clone passes here —
// also assert field INP in RUM to catch sub-threshold-but-frequent stalls.
Diff the Performance trace: the wide JSON.parse frame on the Main track should be gone (Fix 1) or replaced by a row of sub-30ms chunks (Fix 2). In Lighthouse CI, assert total-blocking-time stays under 200ms. Finally, watch field INP at the p75 in your RUM dashboard across the deploy — the parse stall shows most on low-end hardware, so treat the change as proven only when the field p75 drops. If you find the total work is acceptable and only its shape is wrong, the lighter alternative is splitting it with scheduler.yield() instead of paying worker overhead.
Watch for one false positive specific to this fix: a trace can look clean because the worker chunk was never emitted by the bundler and the parse silently fell back to the main thread, yet the scripted check still passes if it observed a session where no large payload happened to load. Guard against it by asserting the worker actually ran — for example, have the worker stamp a marker the test can read, or assert the network panel shows the worker chunk being requested. A green TBT with an unchanged field INP is the signature of a worker that exists in the source but never executed in production.
It is also worth re-running the before/after with the attribution build in the field rather than trusting the lab alone. The lab device rarely reproduces the slow tail; a payload that parses in 60ms on a developer laptop can still cost 250ms on a low-end Android, so the only number that closes the ticket is the field p75 moving below 200ms across a real traffic sample. Hold the change in a canary until that field number confirms the win.
Related
- Offloading work to web workers with Comlink — the full worker setup, transferables, and pooling this page draws on.
- Comlink vs raw postMessage for workers — choosing the abstraction for the parse worker.
- Optimizing INP with scheduler.yield() — the cheaper fix when the parse is borderline rather than genuinely heavy.
- Profiling event handlers for INP — pinpointing which interaction the parse actually degrades.