[{"data":1,"prerenderedAt":1516},["ShallowReactive",2],{"content:\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Fmoving-heavy-json-parsing-off-the-main-thread\u002F":3,"surroundings:\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Fmoving-heavy-json-parsing-off-the-main-thread\u002F":1507},{"id":4,"title":5,"body":6,"description":1487,"extension":1488,"meta":1489,"navigation":715,"path":1502,"seo":1503,"stem":1505,"__hash__":1506},"content\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Fmoving-heavy-json-parsing-off-the-main-thread\u002Findex.md","Moving Heavy JSON Parsing Off the Main Thread",{"type":7,"value":8,"toc":1476},"minimark",[9,14,38,52,152,157,163,210,220,227,405,416,420,428,434,448,454,462,466,471,481,620,808,838,842,849,1075,1084,1088,1091,1157,1171,1174,1178,1181,1387,1412,1415,1429,1433,1461,1466,1469,1472],[10,11,13],"h1",{"id":12},"a-large-jsonparse-blocks-the-main-thread-and-spikes-inp","A Large JSON.parse Blocks the Main Thread and Spikes INP",[15,16,17,18,23,24,28,29,33,34,37],"p",{},"This is a specific failure mode within ",[19,20,22],"a",{"href":21},"\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002F","offloading work to web workers with Comlink",", itself part of the interactivity work in ",[19,25,27],{"href":26},"\u002Fcore-web-vitals-measurement\u002F","Core Web Vitals & Measurement",": a single ",[30,31,32],"code",{},"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 ",[30,35,36],{},"200ms"," for any click that overlaps it.",[15,39,40,41,44,45,47,48,51],{},"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 ",[30,42,43],{},"const data = JSON.parse(text)"," on the result. ",[30,46,32],{}," is synchronous and uninterruptible: a 4MB payload routinely costs ",[30,49,50],{},"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.",[15,53,54],{},[55,56,63,64,63,68,63,72,63,82,63,89,63,94,63,102,63,108,63,114,63,119,63,124,63,128,63,132,63,136,63,141,63,145,63,148,63],"svg",{"xmlns":57,"viewBox":58,"width":59,"role":60,"ariaLabel":61,"style":62},"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","0 0 760 270","100%","img","A single synchronous JSON.parse blocks the main thread, while the same parse moved to a worker leaves the main thread free for input","height:auto;max-width:760px;display:block;margin:1.75rem auto;font-family:inherit;color:#001d3d"," ",[65,66,67],"title",{},"Synchronous parse stall vs worker parse",[69,70,71],"desc",{},"Before: one wide JSON.parse blocks input. After: the parse runs in a worker and the main thread stays responsive.",[73,74],"rect",{"x":75,"y":75,"width":76,"height":77,"rx":78,"fill":79,"stroke":80,"style":81},"1","758","268","10","none","currentColor","stroke-opacity:0.18",[83,84,88],"text",{"x":85,"y":86,"fill":80,"style":87},"24","38","font-size:18px;font-weight:700","JSON.parse stall vs worker parse",[83,90,93],{"x":85,"y":91,"fill":80,"style":92},"72","font-size:14px;font-weight:600","Before",[73,95],{"x":85,"y":96,"width":97,"height":86,"rx":98,"fill":99,"stroke":100,"style":101},"84","470","5","#ffc300","#b8860b","fill-opacity:0.22",[83,103,107],{"x":104,"y":105,"fill":80,"style":106},"259","108","font-size:13px;font-weight:600;text-anchor:middle","JSON.parse 300ms (blocks)",[73,109],{"x":110,"y":96,"width":111,"height":86,"rx":98,"fill":112,"stroke":112,"style":113},"510","70","#0466c8","fill-opacity:0.16",[83,115,118],{"x":116,"y":105,"fill":80,"style":117},"545","font-size:13px;text-anchor:middle","click",[83,120,123],{"x":121,"y":105,"fill":80,"style":122},"600","font-size:13px","INP > 200ms",[83,125,127],{"x":85,"y":126,"fill":80,"style":92},"160","After",[73,129],{"x":85,"y":130,"width":131,"height":86,"rx":98,"fill":112,"stroke":112,"style":113},"172","90",[83,133,118],{"x":134,"y":135,"fill":80,"style":117},"69","196",[73,137],{"x":138,"y":130,"width":139,"height":86,"rx":98,"fill":99,"stroke":100,"style":140},"130","450","fill-opacity:0.12",[83,142,144],{"x":143,"y":135,"fill":80,"style":117},"355","parse runs in worker",[83,146,147],{"x":121,"y":135,"fill":80,"style":122},"INP \u003C 200ms",[83,149,151],{"x":85,"y":150,"fill":80,"style":122},"246","The parse cost is identical; only the thread it runs on changes.",[153,154,156],"h2",{"id":155},"rapid-diagnosis-confirm-the-parse-is-the-stall","Rapid Diagnosis: Confirm the Parse Is the Stall",[15,158,159,160,162],{},"Before changing anything, confirm ",[30,161,32],{}," is the blocking function — not the fetch, not rendering the parsed result.",[164,165,166,174,185,195,203],"ol",{},[167,168,169,170,173],"li",{},"Open the Performance panel, enable ",[30,171,172],{},"4x"," CPU throttling, and record while triggering the data load.",[167,175,176,177,180,181,184],{},"Find the long task on the Main track that overlaps the interaction. Anything wider than ",[30,178,179],{},"50ms"," is a long task; a parse stall is usually ",[30,182,183],{},"150ms+",".",[167,186,187,188,191,192,194],{},"Expand the flame chart under that task. A JSON parse stall shows a single wide ",[30,189,190],{},"Parse"," \u002F ",[30,193,32],{}," frame with no children — it is opaque native work, which is the giveaway that you cannot \"optimize the loop,\" only move it.",[167,196,197,198,202],{},"Switch to the ",[199,200,201],"strong",{},"Interactions"," track and read the recorded INP for the click. Confirm its input delay or processing duration aligns with the parse width.",[167,204,205,206,209],{},"Check the Network panel for the response size. A ",[30,207,208],{},"> 1MB"," JSON body that is parsed synchronously is the confirmed culprit.",[15,211,212,213,215,216,184],{},"If the wide frame is ",[30,214,32],{}," 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 ",[19,217,219],{"href":218},"\u002Fcore-web-vitals-measurement\u002Fprofiling-event-handlers-for-inp\u002F","profiling event handlers for INP",[15,221,222,223,226],{},"For a faster field signal before you open DevTools, instrument the parse directly. Wrapping the parse in a ",[30,224,225],{},"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:",[228,229,234],"pre",{"className":230,"code":231,"language":232,"meta":233,"style":233},"language-javascript shiki shiki-themes github-light-high-contrast github-light-high-contrast github-light-high-contrast","function timedParse(text, label) {\n  const start = performance.now();\n  const data = JSON.parse(text);\n  const ms = performance.now() - start;\n  if (ms > 50) navigator.sendBeacon('\u002Frum\u002Fparse', JSON.stringify({ label, ms, bytes: text.length }));\n  return data;\n  \u002F\u002F trade-off: measuring every parse adds a performance.now() pair per call; gate the\n  \u002F\u002F beacon behind a >50ms threshold so you only report parses that can actually hurt INP.\n}\n","javascript","",[30,235,236,265,287,308,331,377,386,393,399],{"__ignoreMap":233},[237,238,241,245,249,253,256,259,262],"span",{"class":239,"line":240},"line",1,[237,242,244],{"class":243},"sP5qI","function",[237,246,248],{"class":247},"ssM3C"," timedParse",[237,250,252],{"class":251},"syybb","(",[237,254,83],{"class":255},"seIZK",[237,257,258],{"class":251},", ",[237,260,261],{"class":255},"label",[237,263,264],{"class":251},") {\n",[237,266,268,271,275,278,281,284],{"class":239,"line":267},2,[237,269,270],{"class":243},"  const",[237,272,274],{"class":273},"sf6mN"," start",[237,276,277],{"class":243}," =",[237,279,280],{"class":251}," performance.",[237,282,283],{"class":247},"now",[237,285,286],{"class":251},"();\n",[237,288,290,292,295,297,300,302,305],{"class":239,"line":289},3,[237,291,270],{"class":243},[237,293,294],{"class":273}," data",[237,296,277],{"class":243},[237,298,299],{"class":273}," JSON",[237,301,184],{"class":251},[237,303,304],{"class":247},"parse",[237,306,307],{"class":251},"(text);\n",[237,309,311,313,316,318,320,322,325,328],{"class":239,"line":310},4,[237,312,270],{"class":243},[237,314,315],{"class":273}," ms",[237,317,277],{"class":243},[237,319,280],{"class":251},[237,321,283],{"class":247},[237,323,324],{"class":251},"() ",[237,326,327],{"class":243},"-",[237,329,330],{"class":251}," start;\n",[237,332,334,337,340,343,346,349,352,354,358,360,363,365,368,371,374],{"class":239,"line":333},5,[237,335,336],{"class":243},"  if",[237,338,339],{"class":251}," (ms ",[237,341,342],{"class":243},">",[237,344,345],{"class":273}," 50",[237,347,348],{"class":251},") navigator.",[237,350,351],{"class":247},"sendBeacon",[237,353,252],{"class":251},[237,355,357],{"class":356},"s-_DF","'\u002Frum\u002Fparse'",[237,359,258],{"class":251},[237,361,362],{"class":273},"JSON",[237,364,184],{"class":251},[237,366,367],{"class":247},"stringify",[237,369,370],{"class":251},"({ label, ms, bytes: text.",[237,372,373],{"class":273},"length",[237,375,376],{"class":251}," }));\n",[237,378,380,383],{"class":239,"line":379},6,[237,381,382],{"class":243},"  return",[237,384,385],{"class":251}," data;\n",[237,387,389],{"class":239,"line":388},7,[237,390,392],{"class":391},"sIIH1","  \u002F\u002F trade-off: measuring every parse adds a performance.now() pair per call; gate the\n",[237,394,396],{"class":239,"line":395},8,[237,397,398],{"class":391},"  \u002F\u002F beacon behind a >50ms threshold so you only report parses that can actually hurt INP.\n",[237,400,402],{"class":239,"line":401},9,[237,403,404],{"class":251},"}\n",[15,406,407,408,411,412,415],{},"A field histogram that shows a heavy tail of ",[30,409,410],{},"200ms+"," parses on low-end devices confirms the problem is worth the worker overhead. A tail that tops out at ",[30,413,414],{},"30ms"," means the parse is not your INP problem and you should profile elsewhere.",[153,417,419],{"id":418},"root-cause-analysis-four-failure-modes-behind-a-parse-stall","Root Cause Analysis: Four Failure Modes Behind a Parse Stall",[15,421,422,63,425,427],{},[199,423,424],{},"1. Synchronous parse on the main thread (the core mechanism).",[30,426,32],{}," 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.",[15,429,430,433],{},[199,431,432],{},"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.",[15,435,436,439,440,443,444,447],{},[199,437,438],{},"3. Parse-then-clone amplification."," The parsed object is handed straight to a Web Worker, a state-management ",[30,441,442],{},"structuredClone",", or ",[30,445,446],{},"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.",[15,449,450,453],{},[199,451,452],{},"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.",[15,455,456,457,461],{},"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 ",[458,459,460],"em",{},"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.",[153,463,465],{"id":464},"step-by-step-resolution","Step-by-Step Resolution",[467,468,470],"h3",{"id":469},"fix-1-parse-in-a-comlink-worker-highest-impact","Fix 1 — Parse in a Comlink worker (highest impact)",[15,472,473,474,480],{},"Move the synchronous parse onto a Web Worker so the main thread stays free. With ",[19,475,479],{"href":476,"rel":477},"https:\u002F\u002Fgithub.com\u002FGoogleChromeLabs\u002Fcomlink",[478],"nofollow","Comlink"," the call reads like a local async function.",[228,482,484],{"className":230,"code":483,"language":232,"meta":233,"style":233},"\u002F\u002F json.worker.js\nimport * as Comlink from 'comlink';\nComlink.expose({\n  parseAndSlim(text) {\n    const data = JSON.parse(text);           \u002F\u002F 300ms runs OFF the main thread now\n    \u002F\u002F Reduce inside the worker so the return clone stays small.\n    return data.items.map(({ id, name, price }) => ({ id, name, price }));\n    \u002F\u002F trade-off: returning the FULL parsed object would clone it back to the main\n    \u002F\u002F thread and reintroduce a stall — always slim the result inside the worker.\n  },\n});\n",[30,485,486,491,514,525,536,557,562,598,603,608,614],{"__ignoreMap":233},[237,487,488],{"class":239,"line":240},[237,489,490],{"class":391},"\u002F\u002F json.worker.js\n",[237,492,493,496,499,502,505,508,511],{"class":239,"line":267},[237,494,495],{"class":243},"import",[237,497,498],{"class":273}," *",[237,500,501],{"class":243}," as",[237,503,504],{"class":251}," Comlink ",[237,506,507],{"class":243},"from",[237,509,510],{"class":356}," 'comlink'",[237,512,513],{"class":251},";\n",[237,515,516,519,522],{"class":239,"line":289},[237,517,518],{"class":251},"Comlink.",[237,520,521],{"class":247},"expose",[237,523,524],{"class":251},"({\n",[237,526,527,530,532,534],{"class":239,"line":310},[237,528,529],{"class":247},"  parseAndSlim",[237,531,252],{"class":251},[237,533,83],{"class":255},[237,535,264],{"class":251},[237,537,538,541,543,545,547,549,551,554],{"class":239,"line":333},[237,539,540],{"class":243},"    const",[237,542,294],{"class":273},[237,544,277],{"class":243},[237,546,299],{"class":273},[237,548,184],{"class":251},[237,550,304],{"class":247},[237,552,553],{"class":251},"(text);           ",[237,555,556],{"class":391},"\u002F\u002F 300ms runs OFF the main thread now\n",[237,558,559],{"class":239,"line":379},[237,560,561],{"class":391},"    \u002F\u002F Reduce inside the worker so the return clone stays small.\n",[237,563,564,567,570,573,576,579,581,584,586,589,592,595],{"class":239,"line":388},[237,565,566],{"class":243},"    return",[237,568,569],{"class":251}," data.items.",[237,571,572],{"class":247},"map",[237,574,575],{"class":251},"(({ ",[237,577,578],{"class":255},"id",[237,580,258],{"class":251},[237,582,583],{"class":255},"name",[237,585,258],{"class":251},[237,587,588],{"class":255},"price",[237,590,591],{"class":251}," }) ",[237,593,594],{"class":243},"=>",[237,596,597],{"class":251}," ({ id, name, price }));\n",[237,599,600],{"class":239,"line":395},[237,601,602],{"class":391},"    \u002F\u002F trade-off: returning the FULL parsed object would clone it back to the main\n",[237,604,605],{"class":239,"line":401},[237,606,607],{"class":391},"    \u002F\u002F thread and reintroduce a stall — always slim the result inside the worker.\n",[237,609,611],{"class":239,"line":610},10,[237,612,613],{"class":251},"  },\n",[237,615,617],{"class":239,"line":616},11,[237,618,619],{"class":251},"});\n",[228,621,623],{"className":230,"code":622,"language":232,"meta":233,"style":233},"\u002F\u002F main.js\nimport * as Comlink from 'comlink';\nconst worker = new Worker(new URL('.\u002Fjson.worker.js', import.meta.url), { type: 'module' });\nconst api = Comlink.wrap(worker);\n\nasync function loadCatalog(url) {\n  const text = await fetch(url).then((r) => r.text()); \u002F\u002F get text, not .json()\n  return api.parseAndSlim(text);                        \u002F\u002F main thread never blocks on parse\n  \u002F\u002F trade-off: the 4MB string is still structured-cloned INTO the worker (~20-40ms);\n  \u002F\u002F worth it to move a 300ms parse off-thread, but pointless for payloads under ~50KB.\n}\n",[30,624,625,630,646,693,711,717,735,778,794,799,804],{"__ignoreMap":233},[237,626,627],{"class":239,"line":240},[237,628,629],{"class":391},"\u002F\u002F main.js\n",[237,631,632,634,636,638,640,642,644],{"class":239,"line":267},[237,633,495],{"class":243},[237,635,498],{"class":273},[237,637,501],{"class":243},[237,639,504],{"class":251},[237,641,507],{"class":243},[237,643,510],{"class":356},[237,645,513],{"class":251},[237,647,648,651,654,656,659,662,664,667,670,672,675,677,679,681,684,687,690],{"class":239,"line":289},[237,649,650],{"class":243},"const",[237,652,653],{"class":273}," worker",[237,655,277],{"class":243},[237,657,658],{"class":243}," new",[237,660,661],{"class":247}," Worker",[237,663,252],{"class":251},[237,665,666],{"class":243},"new",[237,668,669],{"class":247}," URL",[237,671,252],{"class":251},[237,673,674],{"class":356},"'.\u002Fjson.worker.js'",[237,676,258],{"class":251},[237,678,495],{"class":243},[237,680,184],{"class":251},[237,682,683],{"class":273},"meta",[237,685,686],{"class":251},".url), { type: ",[237,688,689],{"class":356},"'module'",[237,691,692],{"class":251}," });\n",[237,694,695,697,700,702,705,708],{"class":239,"line":310},[237,696,650],{"class":243},[237,698,699],{"class":273}," api",[237,701,277],{"class":243},[237,703,704],{"class":251}," Comlink.",[237,706,707],{"class":247},"wrap",[237,709,710],{"class":251},"(worker);\n",[237,712,713],{"class":239,"line":333},[237,714,716],{"emptyLinePlaceholder":715},true,"\n",[237,718,719,722,725,728,730,733],{"class":239,"line":379},[237,720,721],{"class":243},"async",[237,723,724],{"class":243}," function",[237,726,727],{"class":247}," loadCatalog",[237,729,252],{"class":251},[237,731,732],{"class":255},"url",[237,734,264],{"class":251},[237,736,737,739,742,744,747,750,753,756,759,762,765,767,770,772,775],{"class":239,"line":388},[237,738,270],{"class":243},[237,740,741],{"class":273}," text",[237,743,277],{"class":243},[237,745,746],{"class":243}," await",[237,748,749],{"class":247}," fetch",[237,751,752],{"class":251},"(url).",[237,754,755],{"class":247},"then",[237,757,758],{"class":251},"((",[237,760,761],{"class":255},"r",[237,763,764],{"class":251},") ",[237,766,594],{"class":243},[237,768,769],{"class":251}," r.",[237,771,83],{"class":247},[237,773,774],{"class":251},"()); ",[237,776,777],{"class":391},"\u002F\u002F get text, not .json()\n",[237,779,780,782,785,788,791],{"class":239,"line":395},[237,781,382],{"class":243},[237,783,784],{"class":251}," api.",[237,786,787],{"class":247},"parseAndSlim",[237,789,790],{"class":251},"(text);                        ",[237,792,793],{"class":391},"\u002F\u002F main thread never blocks on parse\n",[237,795,796],{"class":239,"line":401},[237,797,798],{"class":391},"  \u002F\u002F trade-off: the 4MB string is still structured-cloned INTO the worker (~20-40ms);\n",[237,800,801],{"class":239,"line":610},[237,802,803],{"class":391},"  \u002F\u002F worth it to move a 300ms parse off-thread, but pointless for payloads under ~50KB.\n",[237,805,806],{"class":239,"line":616},[237,807,404],{"class":251},[15,809,810,813,814,817,818,821,822,825,826,829,830,833,834,837],{},[199,811,812],{},"Expected outcome:"," removes the parse from the main thread entirely; the parse-driven long task drops from ",[30,815,816],{},"~300ms"," to ",[30,819,820],{},"0ms",", reducing the overlapping interaction's INP by roughly the full parse width (commonly ",[30,823,824],{},"200–300ms","). The remaining cost is the argument clone into the worker. Note the deliberate ",[30,827,828],{},"r.text()"," rather than ",[30,831,832],{},"r.json()"," — calling ",[30,835,836],{},".json()"," would parse on the main thread before you ever reach the worker, defeating the entire fix.",[467,839,841],{"id":840},"fix-2-stream-and-parse-in-chunks-during-the-fetch","Fix 2 — Stream and parse in chunks during the fetch",[15,843,844,845,848],{},"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 ",[30,846,847],{},"300ms"," stall into many small chunks interleaved with the network.",[228,850,852],{"className":230,"code":851,"language":232,"meta":233,"style":233},"import { parser } from 'stream-json';\nimport { streamArray } from 'stream-json\u002Fstreamers\u002FStreamArray';\n\nasync function streamItems(url, onItem) {\n  const res = await fetch(url);\n  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();\n  const pipeline = parser().pipe(streamArray());\n  pipeline.on('data', ({ value }) => onItem(value)); \u002F\u002F emit items as they parse\n  for (let r; !(r = await reader.read()).done; ) pipeline.write(r.value);\n  pipeline.end();\n  \u002F\u002F trade-off: streaming parsers are slower per-byte than native JSON.parse and add a\n  \u002F\u002F dependency; use this only when you can render incrementally or the body truly streams.\n}\n",[30,853,854,868,882,886,906,922,952,978,1010,1050,1059,1064,1070],{"__ignoreMap":233},[237,855,856,858,861,863,866],{"class":239,"line":240},[237,857,495],{"class":243},[237,859,860],{"class":251}," { parser } ",[237,862,507],{"class":243},[237,864,865],{"class":356}," 'stream-json'",[237,867,513],{"class":251},[237,869,870,872,875,877,880],{"class":239,"line":267},[237,871,495],{"class":243},[237,873,874],{"class":251}," { streamArray } ",[237,876,507],{"class":243},[237,878,879],{"class":356}," 'stream-json\u002Fstreamers\u002FStreamArray'",[237,881,513],{"class":251},[237,883,884],{"class":239,"line":289},[237,885,716],{"emptyLinePlaceholder":715},[237,887,888,890,892,895,897,899,901,904],{"class":239,"line":310},[237,889,721],{"class":243},[237,891,724],{"class":243},[237,893,894],{"class":247}," streamItems",[237,896,252],{"class":251},[237,898,732],{"class":255},[237,900,258],{"class":251},[237,902,903],{"class":255},"onItem",[237,905,264],{"class":251},[237,907,908,910,913,915,917,919],{"class":239,"line":333},[237,909,270],{"class":243},[237,911,912],{"class":273}," res",[237,914,277],{"class":243},[237,916,746],{"class":243},[237,918,749],{"class":247},[237,920,921],{"class":251},"(url);\n",[237,923,924,926,929,931,934,937,939,941,944,947,950],{"class":239,"line":379},[237,925,270],{"class":243},[237,927,928],{"class":273}," reader",[237,930,277],{"class":243},[237,932,933],{"class":251}," res.body.",[237,935,936],{"class":247},"pipeThrough",[237,938,252],{"class":251},[237,940,666],{"class":243},[237,942,943],{"class":247}," TextDecoderStream",[237,945,946],{"class":251},"()).",[237,948,949],{"class":247},"getReader",[237,951,286],{"class":251},[237,953,954,956,959,961,964,967,970,972,975],{"class":239,"line":388},[237,955,270],{"class":243},[237,957,958],{"class":273}," pipeline",[237,960,277],{"class":243},[237,962,963],{"class":247}," parser",[237,965,966],{"class":251},"().",[237,968,969],{"class":247},"pipe",[237,971,252],{"class":251},[237,973,974],{"class":247},"streamArray",[237,976,977],{"class":251},"());\n",[237,979,980,983,986,988,991,994,997,999,1001,1004,1007],{"class":239,"line":395},[237,981,982],{"class":251},"  pipeline.",[237,984,985],{"class":247},"on",[237,987,252],{"class":251},[237,989,990],{"class":356},"'data'",[237,992,993],{"class":251},", ({ ",[237,995,996],{"class":255},"value",[237,998,591],{"class":251},[237,1000,594],{"class":243},[237,1002,1003],{"class":247}," onItem",[237,1005,1006],{"class":251},"(value)); ",[237,1008,1009],{"class":391},"\u002F\u002F emit items as they parse\n",[237,1011,1012,1015,1018,1021,1024,1027,1030,1033,1035,1038,1041,1044,1047],{"class":239,"line":401},[237,1013,1014],{"class":243},"  for",[237,1016,1017],{"class":251}," (",[237,1019,1020],{"class":243},"let",[237,1022,1023],{"class":251}," r; ",[237,1025,1026],{"class":243},"!",[237,1028,1029],{"class":251},"(r ",[237,1031,1032],{"class":243},"=",[237,1034,746],{"class":243},[237,1036,1037],{"class":251}," reader.",[237,1039,1040],{"class":247},"read",[237,1042,1043],{"class":251},"()).done; ) pipeline.",[237,1045,1046],{"class":247},"write",[237,1048,1049],{"class":251},"(r.value);\n",[237,1051,1052,1054,1057],{"class":239,"line":610},[237,1053,982],{"class":251},[237,1055,1056],{"class":247},"end",[237,1058,286],{"class":251},[237,1060,1061],{"class":239,"line":616},[237,1062,1063],{"class":391},"  \u002F\u002F trade-off: streaming parsers are slower per-byte than native JSON.parse and add a\n",[237,1065,1067],{"class":239,"line":1066},12,[237,1068,1069],{"class":391},"  \u002F\u002F dependency; use this only when you can render incrementally or the body truly streams.\n",[237,1071,1073],{"class":239,"line":1072},13,[237,1074,404],{"class":251},[15,1076,1077,1079,1080,1083],{},[199,1078,812],{}," the largest single task drops from the full parse width to one chunk's worth, typically ",[30,1081,1082],{},"\u003C 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.",[467,1085,1087],{"id":1086},"fix-3-slim-the-payload-at-the-source-cheapest-if-you-own-the-api","Fix 3 — Slim the payload at the source (cheapest if you own the API)",[15,1089,1090],{},"The fastest parse is the one over fewer bytes. Push field selection to the server so the client parses only what it renders.",[228,1092,1094],{"className":230,"code":1093,"language":232,"meta":233,"style":233},"\u002F\u002F Request only the fields the view binds to.\nconst url = '\u002Fapi\u002Fcatalog?fields=id,name,price&page=1&limit=200';\nconst items = await fetch(url).then((r) => r.json());\n\u002F\u002F trade-off: narrowing fields couples the client to a query contract and risks N+1\n\u002F\u002F follow-up requests when a detail view later needs the dropped fields — paginate too.\n",[30,1095,1096,1101,1115,1147,1152],{"__ignoreMap":233},[237,1097,1098],{"class":239,"line":240},[237,1099,1100],{"class":391},"\u002F\u002F Request only the fields the view binds to.\n",[237,1102,1103,1105,1108,1110,1113],{"class":239,"line":267},[237,1104,650],{"class":243},[237,1106,1107],{"class":273}," url",[237,1109,277],{"class":243},[237,1111,1112],{"class":356}," '\u002Fapi\u002Fcatalog?fields=id,name,price&page=1&limit=200'",[237,1114,513],{"class":251},[237,1116,1117,1119,1122,1124,1126,1128,1130,1132,1134,1136,1138,1140,1142,1145],{"class":239,"line":289},[237,1118,650],{"class":243},[237,1120,1121],{"class":273}," items",[237,1123,277],{"class":243},[237,1125,746],{"class":243},[237,1127,749],{"class":247},[237,1129,752],{"class":251},[237,1131,755],{"class":247},[237,1133,758],{"class":251},[237,1135,761],{"class":255},[237,1137,764],{"class":251},[237,1139,594],{"class":243},[237,1141,769],{"class":251},[237,1143,1144],{"class":247},"json",[237,1146,977],{"class":251},[237,1148,1149],{"class":239,"line":310},[237,1150,1151],{"class":391},"\u002F\u002F trade-off: narrowing fields couples the client to a query contract and risks N+1\n",[237,1153,1154],{"class":239,"line":333},[237,1155,1156],{"class":391},"\u002F\u002F follow-up requests when a detail view later needs the dropped fields — paginate too.\n",[15,1158,1159,1161,1162,817,1164,1167,1168,1170],{},[199,1160,812],{}," parse time scales down roughly linearly with bytes removed; dropping a payload from 4MB to 800KB cuts the parse from ",[30,1163,816],{},[30,1165,1166],{},"~60ms",", often enough on its own to clear the ",[30,1169,36],{}," 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.",[15,1172,1173],{},"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.",[153,1175,1177],{"id":1176},"verification-beforeafter-and-ci","Verification: Before\u002FAfter and CI",[15,1179,1180],{},"Confirm the win in the lab and the field, not by inspection.",[228,1182,1184],{"className":230,"code":1183,"language":232,"meta":233,"style":233},"\u002F\u002F Scripted check (Playwright): assert no single task blocks during the data load.\nconst longTasks = await page.evaluate(() => new Promise((resolve) => {\n  const entries = [];\n  new PerformanceObserver((l) => entries.push(...l.getEntries().map((e) => e.duration)))\n    .observe({ type: 'longtask', buffered: true });\n  setTimeout(() => resolve(entries), 3000);\n}));\nexpect(Math.max(0, ...longTasks)).toBeLessThan(50); \u002F\u002F no long task during the load\n\u002F\u002F trade-off: longtask granularity is 50ms, so a 48ms residual clone passes here —\n\u002F\u002F also assert field INP in RUM to catch sub-threshold-but-frequent stalls.\n",[30,1185,1186,1191,1230,1242,1292,1314,1335,1340,1377,1382],{"__ignoreMap":233},[237,1187,1188],{"class":239,"line":240},[237,1189,1190],{"class":391},"\u002F\u002F Scripted check (Playwright): assert no single task blocks during the data load.\n",[237,1192,1193,1195,1198,1200,1202,1205,1208,1211,1213,1215,1218,1220,1223,1225,1227],{"class":239,"line":267},[237,1194,650],{"class":243},[237,1196,1197],{"class":273}," longTasks",[237,1199,277],{"class":243},[237,1201,746],{"class":243},[237,1203,1204],{"class":251}," page.",[237,1206,1207],{"class":247},"evaluate",[237,1209,1210],{"class":251},"(() ",[237,1212,594],{"class":243},[237,1214,658],{"class":243},[237,1216,1217],{"class":273}," Promise",[237,1219,758],{"class":251},[237,1221,1222],{"class":255},"resolve",[237,1224,764],{"class":251},[237,1226,594],{"class":243},[237,1228,1229],{"class":251}," {\n",[237,1231,1232,1234,1237,1239],{"class":239,"line":289},[237,1233,270],{"class":243},[237,1235,1236],{"class":273}," entries",[237,1238,277],{"class":243},[237,1240,1241],{"class":251}," [];\n",[237,1243,1244,1247,1250,1252,1255,1257,1259,1262,1265,1267,1270,1273,1276,1278,1280,1282,1285,1287,1289],{"class":239,"line":310},[237,1245,1246],{"class":243},"  new",[237,1248,1249],{"class":247}," PerformanceObserver",[237,1251,758],{"class":251},[237,1253,1254],{"class":255},"l",[237,1256,764],{"class":251},[237,1258,594],{"class":243},[237,1260,1261],{"class":251}," entries.",[237,1263,1264],{"class":247},"push",[237,1266,252],{"class":251},[237,1268,1269],{"class":243},"...",[237,1271,1272],{"class":251},"l.",[237,1274,1275],{"class":247},"getEntries",[237,1277,966],{"class":251},[237,1279,572],{"class":247},[237,1281,758],{"class":251},[237,1283,1284],{"class":255},"e",[237,1286,764],{"class":251},[237,1288,594],{"class":243},[237,1290,1291],{"class":251}," e.duration)))\n",[237,1293,1294,1297,1300,1303,1306,1309,1312],{"class":239,"line":333},[237,1295,1296],{"class":251},"    .",[237,1298,1299],{"class":247},"observe",[237,1301,1302],{"class":251},"({ type: ",[237,1304,1305],{"class":356},"'longtask'",[237,1307,1308],{"class":251},", buffered: ",[237,1310,1311],{"class":273},"true",[237,1313,692],{"class":251},[237,1315,1316,1319,1321,1323,1326,1329,1332],{"class":239,"line":379},[237,1317,1318],{"class":247},"  setTimeout",[237,1320,1210],{"class":251},[237,1322,594],{"class":243},[237,1324,1325],{"class":247}," resolve",[237,1327,1328],{"class":251},"(entries), ",[237,1330,1331],{"class":273},"3000",[237,1333,1334],{"class":251},");\n",[237,1336,1337],{"class":239,"line":388},[237,1338,1339],{"class":251},"}));\n",[237,1341,1342,1345,1348,1351,1353,1356,1358,1360,1363,1366,1368,1371,1374],{"class":239,"line":395},[237,1343,1344],{"class":247},"expect",[237,1346,1347],{"class":251},"(Math.",[237,1349,1350],{"class":247},"max",[237,1352,252],{"class":251},[237,1354,1355],{"class":273},"0",[237,1357,258],{"class":251},[237,1359,1269],{"class":243},[237,1361,1362],{"class":251},"longTasks)).",[237,1364,1365],{"class":247},"toBeLessThan",[237,1367,252],{"class":251},[237,1369,1370],{"class":273},"50",[237,1372,1373],{"class":251},"); ",[237,1375,1376],{"class":391},"\u002F\u002F no long task during the load\n",[237,1378,1379],{"class":239,"line":401},[237,1380,1381],{"class":391},"\u002F\u002F trade-off: longtask granularity is 50ms, so a 48ms residual clone passes here —\n",[237,1383,1384],{"class":239,"line":610},[237,1385,1386],{"class":391},"\u002F\u002F also assert field INP in RUM to catch sub-threshold-but-frequent stalls.\n",[15,1388,1389,1390,1392,1393,1395,1396,1399,1400,1402,1403,1406,1407,1411],{},"Diff the Performance trace: the wide ",[30,1391,32],{}," frame on the Main track should be gone (Fix 1) or replaced by a row of sub-",[30,1394,414],{}," chunks (Fix 2). In Lighthouse CI, assert ",[30,1397,1398],{},"total-blocking-time"," stays under ",[30,1401,36],{},". 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 ",[458,1404,1405],{},"total"," work is acceptable and only its shape is wrong, the lighter alternative is ",[19,1408,1410],{"href":1409},"\u002Fcore-web-vitals-measurement\u002Foptimizing-inp-with-scheduler-yield\u002F","splitting it with scheduler.yield()"," instead of paying worker overhead.",[15,1413,1414],{},"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.",[15,1416,1417,1418,1421,1422,1425,1426,1428],{},"It is also worth re-running the before\u002Fafter 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 ",[30,1419,1420],{},"60ms"," on a developer laptop can still cost ",[30,1423,1424],{},"250ms"," on a low-end Android, so the only number that closes the ticket is the field p75 moving below ",[30,1427,36],{}," across a real traffic sample. Hold the change in a canary until that field number confirms the win.",[153,1430,1432],{"id":1431},"related","Related",[1434,1435,1436,1442,1449,1455],"ul",{},[167,1437,1438,1441],{},[19,1439,1440],{"href":21},"Offloading work to web workers with Comlink"," — the full worker setup, transferables, and pooling this page draws on.",[167,1443,1444,1448],{},[19,1445,1447],{"href":1446},"\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Fcomlink-vs-raw-postmessage-for-workers\u002F","Comlink vs raw postMessage for workers"," — choosing the abstraction for the parse worker.",[167,1450,1451,1454],{},[19,1452,1453],{"href":1409},"Optimizing INP with scheduler.yield()"," — the cheaper fix when the parse is borderline rather than genuinely heavy.",[167,1456,1457,1460],{},[19,1458,1459],{"href":218},"Profiling event handlers for INP"," — pinpointing which interaction the parse actually degrades.",[1462,1463,1465],"script",{"type":1464},"application\u002Fld+json","\n{\n  \"@context\": \"https:\u002F\u002Fschema.org\",\n  \"@type\": \"HowTo\",\n  \"name\": \"Moving Heavy JSON Parsing Off the Main Thread\",\n  \"description\": \"Diagnose and fix an INP spike caused by a large synchronous JSON.parse blocking the main thread.\",\n  \"step\": [\n    { \"@type\": \"HowToStep\", \"name\": \"Parse in a Comlink worker\", \"url\": \"https:\u002F\u002Ffrontend-performance.com\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Fmoving-heavy-json-parsing-off-the-main-thread\u002F#fix-1-parse-in-a-comlink-worker-highest-impact\" },\n    { \"@type\": \"HowToStep\", \"name\": \"Stream and parse in chunks during the fetch\", \"url\": \"https:\u002F\u002Ffrontend-performance.com\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Fmoving-heavy-json-parsing-off-the-main-thread\u002F#fix-2-stream-and-parse-in-chunks-during-the-fetch\" },\n    { \"@type\": \"HowToStep\", \"name\": \"Slim the payload at the source\", \"url\": \"https:\u002F\u002Ffrontend-performance.com\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Fmoving-heavy-json-parsing-off-the-main-thread\u002F#fix-3-slim-the-payload-at-the-source-cheapest-if-you-own-the-api\" }\n  ]\n}\n",[1462,1467,1468],{"type":1464},"\n{\n  \"@context\": \"https:\u002F\u002Fschema.org\",\n  \"@type\": \"TechArticle\",\n  \"headline\": \"A Large JSON.parse Blocks the Main Thread and Spikes INP\",\n  \"description\": \"Diagnose a synchronous JSON.parse stall and fix it with a Comlink worker parse, streaming chunked parse, or schema slimming.\",\n  \"datePublished\": \"2026-06-18\",\n  \"dateModified\": \"2026-06-18\",\n  \"mainEntityOfPage\": \"https:\u002F\u002Ffrontend-performance.com\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Fmoving-heavy-json-parsing-off-the-main-thread\u002F\"\n}\n",[1462,1470,1471],{"type":1464},"\n{\n  \"@context\": \"https:\u002F\u002Fschema.org\",\n  \"@type\": \"BreadcrumbList\",\n  \"itemListElement\": [\n    { \"@type\": \"ListItem\", \"position\": 1, \"name\": \"Home\", \"item\": \"https:\u002F\u002Ffrontend-performance.com\u002F\" },\n    { \"@type\": \"ListItem\", \"position\": 2, \"name\": \"Core Web Vitals & Measurement\", \"item\": \"https:\u002F\u002Ffrontend-performance.com\u002Fcore-web-vitals-measurement\u002F\" },\n    { \"@type\": \"ListItem\", \"position\": 3, \"name\": \"Offloading Work to Web Workers with Comlink\", \"item\": \"https:\u002F\u002Ffrontend-performance.com\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002F\" },\n    { \"@type\": \"ListItem\", \"position\": 4, \"name\": \"Moving Heavy JSON Parsing Off the Main Thread\", \"item\": \"https:\u002F\u002Ffrontend-performance.com\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Fmoving-heavy-json-parsing-off-the-main-thread\u002F\" }\n  ]\n}\n",[1473,1474,1475],"style",{},"html pre.shiki code .sP5qI, html code.shiki .sP5qI{--shiki-default:#A0111F;--shiki-dark:#A0111F;--shiki-light:#A0111F}html pre.shiki code .ssM3C, html code.shiki .ssM3C{--shiki-default:#622CBC;--shiki-dark:#622CBC;--shiki-light:#622CBC}html pre.shiki code .syybb, html code.shiki .syybb{--shiki-default:#0E1116;--shiki-dark:#0E1116;--shiki-light:#0E1116}html pre.shiki code .seIZK, html code.shiki .seIZK{--shiki-default:#702C00;--shiki-dark:#702C00;--shiki-light:#702C00}html pre.shiki code .sf6mN, html code.shiki .sf6mN{--shiki-default:#023B95;--shiki-dark:#023B95;--shiki-light:#023B95}html pre.shiki code .s-_DF, html code.shiki .s-_DF{--shiki-default:#032563;--shiki-dark:#032563;--shiki-light:#032563}html pre.shiki code .sIIH1, html code.shiki .sIIH1{--shiki-default:#66707B;--shiki-dark:#66707B;--shiki-light:#66707B}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}",{"title":233,"searchDepth":267,"depth":267,"links":1477},[1478,1479,1480,1485,1486],{"id":155,"depth":267,"text":156},{"id":418,"depth":267,"text":419},{"id":464,"depth":267,"text":465,"children":1481},[1482,1483,1484],{"id":469,"depth":289,"text":470},{"id":840,"depth":289,"text":841},{"id":1086,"depth":289,"text":1087},{"id":1176,"depth":267,"text":1177},{"id":1431,"depth":267,"text":1432},"Fix the INP spike from a large synchronous JSON.parse by parsing in a worker, streaming, or slimming the payload.","md",{"slug":1490,"type":1491,"breadcrumb":1492,"datePublished":1501,"dateModified":1501},"moving-heavy-json-parsing-off-the-main-thread","long_tail",[1493,1496,1497,1499],{"name":1494,"url":1495},"Home","\u002F",{"name":27,"url":26},{"name":1498,"url":21},"Offloading Work to Web Workers with Comlink",{"name":5,"url":1500},"\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Fmoving-heavy-json-parsing-off-the-main-thread\u002F","2026-06-18","\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Fmoving-heavy-json-parsing-off-the-main-thread",{"title":5,"description":1504},"A large JSON.parse blocks the main thread and spikes INP. Diagnose the stall, then fix it with a Comlink worker parse, streaming chunked parse, and schema slimming.","core-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Fmoving-heavy-json-parsing-off-the-main-thread\u002Findex","YpQRXAP2wW-Nwfwc5WjX31ayncu3ZUs4SiF5ExIHGfQ",[1508,1512],{"title":1509,"path":1510,"stem":1511,"children":-1},"Comlink vs Raw postMessage for Workers","\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Fcomlink-vs-raw-postmessage-for-workers","core-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Fcomlink-vs-raw-postmessage-for-workers\u002Findex",{"title":1513,"path":1514,"stem":1515,"children":-1},"Optimizing First Input Delay (FID) and INP","\u002Fcore-web-vitals-measurement\u002Foptimizing-first-input-delay-fid","core-web-vitals-measurement\u002Foptimizing-first-input-delay-fid\u002Findex",1782237171390]