[{"data":1,"prerenderedAt":2158},["ShallowReactive",2],{"content:\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002F":3,"surroundings:\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002F":2149},{"id":4,"title":5,"body":6,"description":2131,"extension":2132,"meta":2133,"navigation":345,"path":2144,"seo":2145,"stem":2147,"__hash__":2148},"content\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Findex.md","Offloading Work to Web Workers with Comlink",{"type":7,"value":8,"toc":2110},"minimark",[9,14,24,41,59,180,185,209,223,227,234,277,288,292,303,479,486,615,627,631,638,766,784,788,791,836,847,851,856,887,1010,1019,1023,1030,1266,1277,1281,1292,1337,1358,1362,1372,1386,1395,1409,1415,1419,1422,1426,1429,1577,1586,1590,1602,1663,1672,1676,1679,1839,1844,1848,1923,1927,1930,2043,2058,2062,2095,2100,2103,2106],[10,11,13],"h1",{"id":12},"offloading-work-to-web-workers-with-comlink-protecting-inp-by-moving-heavy-work-off-the-main-thread","Offloading Work to Web Workers with Comlink: Protecting INP by Moving Heavy Work Off the Main Thread",[15,16,17,18,23],"p",{},"This guide extends the interactivity engineering in ",[19,20,22],"a",{"href":21},"\u002Fcore-web-vitals-measurement\u002F","Core Web Vitals & Measurement"," to the case where a task is simply too expensive to run on the main thread at all, no matter how you schedule it.",[15,25,26,27,31,32,36,37,40],{},"There is a hard ceiling on what cooperative scheduling can buy you. Slicing a long task into chunks and yielding between them keeps a click responsive only while the ",[28,29,30],"em",{},"total"," work fits the frame budget across a handful of yields. When a single operation — parsing a 4MB JSON payload, hashing a file, decoding and resizing an image, diffing two large trees, or building a search index — costs ",[33,34,35],"code",{},"300ms"," or more of pure CPU, there is no chunk boundary cheap enough to hide it. Every millisecond it runs is a millisecond the main thread cannot run an event listener or paint a frame, and Interaction to Next Paint (INP) reports the worst interaction across the visit, so one such operation overlapping a click fails the ",[33,38,39],{},"200ms"," \"good\" boundary outright. The only structural fix is to move that CPU off the main thread entirely, onto a Web Worker, and communicate the result back.",[15,42,43,44,47,48,54,55,58],{},"Raw Web Workers make that move painful: you wire up ",[33,45,46],{},"postMessage",", hand-roll a message-type protocol, correlate responses to requests, and serialize everything by hand. ",[19,49,53],{"href":50,"rel":51},"https:\u002F\u002Fgithub.com\u002FGoogleChromeLabs\u002Fcomlink",[52],"nofollow","Comlink"," (1.1KB gzipped) wraps the worker boundary in a Proxy so a worker's exported functions look like ordinary async methods on the main thread — you ",[33,56,57],{},"await worker.parseJSON(text)"," and Comlink handles the message round-trip, the request\u002Fresponse correlation, and the structured-clone marshalling underneath. This guide walks the full loop: decide what is worth offloading, set up Comlink in Vite or webpack, manage worker lifecycle and pooling, use Transferable objects to avoid copy cost, and measure that INP actually moved in the field rather than assuming it did.",[15,60,61],{},[62,63,70,71,70,75,70,79,70,89,70,96,70,104,70,110,70,116,70,120,70,128,70,133,70,139,70,143,70,148,70,154,70,159,70,164,70,167,70,171,70,175,70],"svg",{"xmlns":64,"viewBox":65,"width":66,"role":67,"ariaLabel":68,"style":69},"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","0 0 760 330","100%","img","Main thread calls an async Comlink proxy method, the worker runs the heavy job off-thread, and the result transfers back so input stays responsive","height:auto;max-width:760px;display:block;margin:1.75rem auto;font-family:inherit;color:#001d3d"," ",[72,73,74],"title",{},"Comlink RPC across the worker boundary",[76,77,78],"desc",{},"The main thread awaits a proxied method, the worker runs the CPU-bound job, and only the result crosses back, keeping the main thread free for input.",[80,81],"rect",{"x":82,"y":82,"width":83,"height":84,"rx":85,"fill":86,"stroke":87,"style":88},"1","758","328","10","none","currentColor","stroke-opacity:0.18",[90,91,95],"text",{"x":92,"y":93,"fill":87,"style":94},"24","38","font-size:18px;font-weight:700","Offloading heavy work over Comlink RPC",[80,97],{"x":92,"y":98,"width":99,"height":100,"rx":101,"fill":102,"stroke":102,"style":103},"64","320","230","8","#0466c8","fill-opacity:0.08",[80,105],{"x":106,"y":98,"width":99,"height":100,"rx":101,"fill":107,"stroke":108,"style":109},"416","#ffc300","#b8860b","fill-opacity:0.10",[90,111,115],{"x":112,"y":113,"fill":87,"style":114},"184","90","font-size:14px;font-weight:700;text-anchor:middle","Main thread",[90,117,119],{"x":118,"y":113,"fill":87,"style":114},"576","Web Worker",[80,121],{"x":122,"y":123,"width":124,"height":125,"rx":126,"fill":102,"stroke":102,"style":127},"48","108","272","40","5","fill-opacity:0.16",[90,129,132],{"x":112,"y":130,"fill":87,"style":131},"133","font-size:13px;text-anchor:middle","await api.parseJSON(text)",[80,134],{"x":135,"y":136,"width":124,"height":137,"rx":126,"fill":107,"stroke":108,"style":138},"440","178","44","fill-opacity:0.22",[90,140,142],{"x":118,"y":141,"fill":87,"style":131},"198","heavy CPU job runs here",[90,144,147],{"x":118,"y":145,"fill":87,"style":146},"215","font-size:12px;text-anchor:middle","300ms off-thread",[149,150],"line",{"x1":99,"y1":151,"x2":135,"y2":152,"stroke":87,"style":153},"128","196","stroke-opacity:0.5;stroke-width:2",[90,155,158],{"x":156,"y":157,"fill":87,"style":146},"380","150","call",[149,160],{"x1":135,"y1":161,"x2":99,"y2":162,"stroke":108,"style":163},"200","244","stroke-width:2",[80,165],{"x":122,"y":166,"width":124,"height":125,"rx":126,"fill":102,"stroke":102,"style":127},"226",[90,168,170],{"x":112,"y":169,"fill":87,"style":131},"251","result resolves the promise",[90,172,174],{"x":156,"y":173,"fill":87,"style":146},"222","transfer",[90,176,179],{"x":92,"y":177,"fill":87,"style":178},"318","font-size:13px","The main thread stays free to handle clicks while the worker is busy — INP holds under 200ms.",[181,182,184],"h2",{"id":183},"problem-framing-when-scheduling-stops-being-enough","Problem Framing: When Scheduling Stops Being Enough",[15,186,187,188,191,192,195,196,200,201,204,205,208],{},"The decision between scheduling and offloading turns on one question: is the total work acceptable, or is its ",[28,189,190],{},"shape"," wrong? If a ",[33,193,194],{},"220ms"," operation is acceptable in aggregate and only hurts because it runs as one unbroken block, ",[19,197,199],{"href":198},"\u002Fcore-web-vitals-measurement\u002Foptimizing-inp-with-scheduler-yield\u002F","splitting it with scheduler.yield()"," is the cheaper fix — no serialization cost, no worker to manage. But if the operation is genuinely expensive and runs while users interact, yielding only makes it ",[28,202,203],{},"interruptible",", not cheap; the total wall-clock cost still competes with rendering across every chunk boundary. A worker removes that competition: the CPU runs on a different thread, the main thread stays at a ",[33,206,207],{},"0ms"," long-task profile during the job, and input is processed within budget the entire time.",[15,210,211,212,215,216,218,219,222],{},"The honest trade is latency for throughput-isolation. A worker call carries fixed overhead — message dispatch, structured clone of the arguments, and the clone of the result coming back — typically ",[33,213,214],{},"1–5ms"," for small payloads but scaling with payload size. For a ",[33,217,35],{}," job that overhead is noise; for a ",[33,220,221],{},"2ms"," job it is pure loss. Offload work whose CPU cost dwarfs the marshalling cost, and keep cheap, latency-sensitive work on the main thread.",[181,224,226],{"id":225},"prerequisites-versions-packages-and-build-flags","Prerequisites: Versions, Packages, and Build Flags",[15,228,229,230,233],{},"This guide assumes Comlink 4.4+, a bundler with first-class worker support (Vite 5+ or webpack 5+), and a browser baseline that supports module workers (",[33,231,232],{},"new Worker(url, { type: 'module' })",") — Chromium 80+, Firefox 114+, Safari 15+. Install Comlink:",[235,236,241],"pre",{"className":237,"code":238,"language":239,"meta":240,"style":240},"language-bash shiki shiki-themes github-light-high-contrast github-light-high-contrast github-light-high-contrast","# trade-off: Comlink adds ~1.1KB gzipped and an async hop to every worker call;\n# if you only ever call one fire-and-forget worker function, raw postMessage is\n# leaner — see the comparison page linked below before adopting it everywhere.\nnpm install comlink\n","bash","",[33,242,243,251,257,263],{"__ignoreMap":240},[244,245,247],"span",{"class":149,"line":246},1,[244,248,250],{"class":249},"sIIH1","# trade-off: Comlink adds ~1.1KB gzipped and an async hop to every worker call;\n",[244,252,254],{"class":149,"line":253},2,[244,255,256],{"class":249},"# if you only ever call one fire-and-forget worker function, raw postMessage is\n",[244,258,260],{"class":149,"line":259},3,[244,261,262],{"class":249},"# leaner — see the comparison page linked below before adopting it everywhere.\n",[244,264,266,270,274],{"class":149,"line":265},4,[244,267,269],{"class":268},"seIZK","npm",[244,271,273],{"class":272},"s-_DF"," install",[244,275,276],{"class":272}," comlink\n",[15,278,279,280,283,284,287],{},"No build flags are required for Vite. For webpack 5, confirm ",[33,281,282],{},"output.workerChunkLoading"," is left at its default and that you are on a version with native ",[33,285,286],{},"new Worker(new URL(...))"," parsing (5.27+). If you target browsers without module-worker support, you will need a classic-worker fallback build; that is the only configuration that meaningfully changes the setup below.",[181,289,291],{"id":290},"_1-environment-setup-wiring-comlink-through-your-bundler","1. Environment Setup: Wiring Comlink Through Your Bundler",[15,293,294,295,298,299,302],{},"The worker file exposes its API with ",[33,296,297],{},"Comlink.expose()","; the main thread wraps the worker with ",[33,300,301],{},"Comlink.wrap()"," to get the typed proxy.",[235,304,308],{"className":305,"code":306,"language":307,"meta":240,"style":240},"language-javascript shiki shiki-themes github-light-high-contrast github-light-high-contrast github-light-high-contrast","\u002F\u002F heavy.worker.js\nimport * as Comlink from 'comlink';\n\nconst api = {\n  parseJSON(text) {\n    return JSON.parse(text); \u002F\u002F runs off the main thread\n  },\n  buildSearchIndex(records) {\n    \u002F\u002F expensive tokenization + inverted index construction\n    return indexRecords(records);\n  },\n};\n\nComlink.expose(api);\n\u002F\u002F trade-off: every exposed method's arguments and return value are structured-cloned\n\u002F\u002F across the boundary, so exposing a method that returns a 50MB object just moves the\n\u002F\u002F copy cost rather than removing it — return Transferables or a slim summary instead.\n","javascript",[33,309,310,315,341,347,361,376,397,403,416,422,433,438,444,449,461,467,473],{"__ignoreMap":240},[244,311,312],{"class":149,"line":246},[244,313,314],{"class":249},"\u002F\u002F heavy.worker.js\n",[244,316,317,321,325,328,332,335,338],{"class":149,"line":253},[244,318,320],{"class":319},"sP5qI","import",[244,322,324],{"class":323},"sf6mN"," *",[244,326,327],{"class":319}," as",[244,329,331],{"class":330},"syybb"," Comlink ",[244,333,334],{"class":319},"from",[244,336,337],{"class":272}," 'comlink'",[244,339,340],{"class":330},";\n",[244,342,343],{"class":149,"line":259},[244,344,346],{"emptyLinePlaceholder":345},true,"\n",[244,348,349,352,355,358],{"class":149,"line":265},[244,350,351],{"class":319},"const",[244,353,354],{"class":323}," api",[244,356,357],{"class":319}," =",[244,359,360],{"class":330}," {\n",[244,362,364,368,371,373],{"class":149,"line":363},5,[244,365,367],{"class":366},"ssM3C","  parseJSON",[244,369,370],{"class":330},"(",[244,372,90],{"class":268},[244,374,375],{"class":330},") {\n",[244,377,379,382,385,388,391,394],{"class":149,"line":378},6,[244,380,381],{"class":319},"    return",[244,383,384],{"class":323}," JSON",[244,386,387],{"class":330},".",[244,389,390],{"class":366},"parse",[244,392,393],{"class":330},"(text); ",[244,395,396],{"class":249},"\u002F\u002F runs off the main thread\n",[244,398,400],{"class":149,"line":399},7,[244,401,402],{"class":330},"  },\n",[244,404,406,409,411,414],{"class":149,"line":405},8,[244,407,408],{"class":366},"  buildSearchIndex",[244,410,370],{"class":330},[244,412,413],{"class":268},"records",[244,415,375],{"class":330},[244,417,419],{"class":149,"line":418},9,[244,420,421],{"class":249},"    \u002F\u002F expensive tokenization + inverted index construction\n",[244,423,425,427,430],{"class":149,"line":424},10,[244,426,381],{"class":319},[244,428,429],{"class":366}," indexRecords",[244,431,432],{"class":330},"(records);\n",[244,434,436],{"class":149,"line":435},11,[244,437,402],{"class":330},[244,439,441],{"class":149,"line":440},12,[244,442,443],{"class":330},"};\n",[244,445,447],{"class":149,"line":446},13,[244,448,346],{"emptyLinePlaceholder":345},[244,450,452,455,458],{"class":149,"line":451},14,[244,453,454],{"class":330},"Comlink.",[244,456,457],{"class":366},"expose",[244,459,460],{"class":330},"(api);\n",[244,462,464],{"class":149,"line":463},15,[244,465,466],{"class":249},"\u002F\u002F trade-off: every exposed method's arguments and return value are structured-cloned\n",[244,468,470],{"class":149,"line":469},16,[244,471,472],{"class":249},"\u002F\u002F across the boundary, so exposing a method that returns a 50MB object just moves the\n",[244,474,476],{"class":149,"line":475},17,[244,477,478],{"class":249},"\u002F\u002F copy cost rather than removing it — return Transferables or a slim summary instead.\n",[15,480,481,482,485],{},"The critical detail for both Vite and webpack is constructing the worker with the ",[33,483,484],{},"new URL(..., import.meta.url)"," form so the bundler can statically discover the worker file, fingerprint it, and emit it as a separate chunk:",[235,487,489],{"className":305,"code":488,"language":307,"meta":240,"style":240},"\u002F\u002F main.js — Vite and webpack 5 both understand this exact form\nimport * as Comlink from 'comlink';\n\n\u002F\u002F The new URL(..., import.meta.url) pattern lets the bundler resolve and hash the\n\u002F\u002F worker as its own asset. A bare string path would NOT be bundled correctly.\nconst worker = new Worker(new URL('.\u002Fheavy.worker.js', import.meta.url), {\n  type: 'module',\n});\nexport const heavy = Comlink.wrap(worker);\n\u002F\u002F trade-off: this creates the worker eagerly at module load, paying ~5-15ms of worker\n\u002F\u002F startup during page init — lazy-create it on first use if the work is rarely needed.\n",[33,490,491,496,512,516,521,526,567,578,583,605,610],{"__ignoreMap":240},[244,492,493],{"class":149,"line":246},[244,494,495],{"class":249},"\u002F\u002F main.js — Vite and webpack 5 both understand this exact form\n",[244,497,498,500,502,504,506,508,510],{"class":149,"line":253},[244,499,320],{"class":319},[244,501,324],{"class":323},[244,503,327],{"class":319},[244,505,331],{"class":330},[244,507,334],{"class":319},[244,509,337],{"class":272},[244,511,340],{"class":330},[244,513,514],{"class":149,"line":259},[244,515,346],{"emptyLinePlaceholder":345},[244,517,518],{"class":149,"line":265},[244,519,520],{"class":249},"\u002F\u002F The new URL(..., import.meta.url) pattern lets the bundler resolve and hash the\n",[244,522,523],{"class":149,"line":363},[244,524,525],{"class":249},"\u002F\u002F worker as its own asset. A bare string path would NOT be bundled correctly.\n",[244,527,528,530,533,535,538,541,543,546,549,551,554,557,559,561,564],{"class":149,"line":378},[244,529,351],{"class":319},[244,531,532],{"class":323}," worker",[244,534,357],{"class":319},[244,536,537],{"class":319}," new",[244,539,540],{"class":366}," Worker",[244,542,370],{"class":330},[244,544,545],{"class":319},"new",[244,547,548],{"class":366}," URL",[244,550,370],{"class":330},[244,552,553],{"class":272},"'.\u002Fheavy.worker.js'",[244,555,556],{"class":330},", ",[244,558,320],{"class":319},[244,560,387],{"class":330},[244,562,563],{"class":323},"meta",[244,565,566],{"class":330},".url), {\n",[244,568,569,572,575],{"class":149,"line":399},[244,570,571],{"class":330},"  type: ",[244,573,574],{"class":272},"'module'",[244,576,577],{"class":330},",\n",[244,579,580],{"class":149,"line":405},[244,581,582],{"class":330},"});\n",[244,584,585,588,591,594,596,599,602],{"class":149,"line":418},[244,586,587],{"class":319},"export",[244,589,590],{"class":319}," const",[244,592,593],{"class":323}," heavy",[244,595,357],{"class":319},[244,597,598],{"class":330}," Comlink.",[244,600,601],{"class":366},"wrap",[244,603,604],{"class":330},"(worker);\n",[244,606,607],{"class":149,"line":424},[244,608,609],{"class":249},"\u002F\u002F trade-off: this creates the worker eagerly at module load, paying ~5-15ms of worker\n",[244,611,612],{"class":149,"line":435},[244,613,614],{"class":249},"\u002F\u002F startup during page init — lazy-create it on first use if the work is rarely needed.\n",[15,616,617,618,621,622,626],{},"With that in place the rest of the app calls ",[33,619,620],{},"await heavy.parseJSON(text)"," as if it were local. Pair this build configuration with your broader splitting strategy in ",[19,623,625],{"href":624},"\u002Fjavascript-bundle-optimization-code-splitting\u002Fdynamic-imports-and-route-based-splitting\u002F","dynamic imports and route-based splitting",", so the worker chunk loads on the route that needs it rather than at first paint.",[181,628,630],{"id":629},"_2-capture-a-baseline-prove-the-work-is-the-bottleneck","2. Capture a Baseline: Prove the Work Is the Bottleneck",[15,632,633,634,637],{},"Before moving anything, confirm the operation is what inflates INP. Record real-user interactions with the ",[33,635,636],{},"web-vitals"," attribution build, which splits each interaction into input delay, processing duration, and presentation delay, and correlate the slow ones with long-task entries.",[235,639,641],{"className":305,"code":640,"language":307,"meta":240,"style":240},"import { onINP } from 'web-vitals\u002Fattribution';\n\nonINP(({ value, attribution }) => {\n  \u002F\u002F processingDuration ballooning + a long-task entry over the same script\n  \u002F\u002F is the signature of a CPU-bound handler that belongs in a worker.\n  navigator.sendBeacon('\u002Frum\u002Finp', JSON.stringify({\n    value,\n    processing: attribution.processingDuration,\n    script: attribution.longAnimationFrameEntries?.[0]?.scripts?.[0]?.sourceURL,\n  }));\n  \u002F\u002F trade-off: attribution build is ~2KB heavier than the core web-vitals build;\n  \u002F\u002F ship it to a traffic sample, not 100% of sessions, once you trust the signal.\n});\n",[33,642,643,657,661,685,690,695,721,726,731,747,752,757,762],{"__ignoreMap":240},[244,644,645,647,650,652,655],{"class":149,"line":246},[244,646,320],{"class":319},[244,648,649],{"class":330}," { onINP } ",[244,651,334],{"class":319},[244,653,654],{"class":272}," 'web-vitals\u002Fattribution'",[244,656,340],{"class":330},[244,658,659],{"class":149,"line":253},[244,660,346],{"emptyLinePlaceholder":345},[244,662,663,666,669,672,674,677,680,683],{"class":149,"line":259},[244,664,665],{"class":366},"onINP",[244,667,668],{"class":330},"(({ ",[244,670,671],{"class":268},"value",[244,673,556],{"class":330},[244,675,676],{"class":268},"attribution",[244,678,679],{"class":330}," }) ",[244,681,682],{"class":319},"=>",[244,684,360],{"class":330},[244,686,687],{"class":149,"line":265},[244,688,689],{"class":249},"  \u002F\u002F processingDuration ballooning + a long-task entry over the same script\n",[244,691,692],{"class":149,"line":363},[244,693,694],{"class":249},"  \u002F\u002F is the signature of a CPU-bound handler that belongs in a worker.\n",[244,696,697,700,703,705,708,710,713,715,718],{"class":149,"line":378},[244,698,699],{"class":330},"  navigator.",[244,701,702],{"class":366},"sendBeacon",[244,704,370],{"class":330},[244,706,707],{"class":272},"'\u002Frum\u002Finp'",[244,709,556],{"class":330},[244,711,712],{"class":323},"JSON",[244,714,387],{"class":330},[244,716,717],{"class":366},"stringify",[244,719,720],{"class":330},"({\n",[244,722,723],{"class":149,"line":399},[244,724,725],{"class":330},"    value,\n",[244,727,728],{"class":149,"line":405},[244,729,730],{"class":330},"    processing: attribution.processingDuration,\n",[244,732,733,736,739,742,744],{"class":149,"line":418},[244,734,735],{"class":330},"    script: attribution.longAnimationFrameEntries?.[",[244,737,738],{"class":323},"0",[244,740,741],{"class":330},"]?.scripts?.[",[244,743,738],{"class":323},[244,745,746],{"class":330},"]?.sourceURL,\n",[244,748,749],{"class":149,"line":424},[244,750,751],{"class":330},"  }));\n",[244,753,754],{"class":149,"line":435},[244,755,756],{"class":249},"  \u002F\u002F trade-off: attribution build is ~2KB heavier than the core web-vitals build;\n",[244,758,759],{"class":149,"line":440},[244,760,761],{"class":249},"  \u002F\u002F ship it to a traffic sample, not 100% of sessions, once you trust the signal.\n",[244,763,764],{"class":149,"line":446},[244,765,582],{"class":330},[15,767,768,769,772,773,775,776,779,780,387],{},"In the lab, open the Performance panel under ",[33,770,771],{},"4x"," CPU throttling, run the interaction, and read the Long Tasks track. A single task wider than ",[33,774,39],{}," whose flame chart is dominated by one function — ",[33,777,778],{},"JSON.parse",", a hashing routine, an image decode — is an offload candidate. Record the worst interaction's INP and the width of that task; every later step is judged against this number. The deeper replay-and-rank workflow lives in ",[19,781,783],{"href":782},"\u002Fcore-web-vitals-measurement\u002Fprofiling-event-handlers-for-inp\u002F","profiling event handlers for INP",[181,785,787],{"id":786},"_3-isolate-the-bottleneck-what-is-actually-worth-offloading","3. Isolate the Bottleneck: What Is Actually Worth Offloading",[15,789,790],{},"Not all heavy work is worker-friendly. The candidates worth moving share two properties: they are CPU-bound (not waiting on I\u002FO), and they take a self-contained input to a self-contained output without touching the DOM.",[792,793,794,808,814,824,830],"ul",{},[795,796,797,70,801,803,804,387],"li",{},[798,799,800],"strong",{},"JSON parsing and serialization.",[33,802,778],{}," of a multi-megabyte payload is the canonical case; it is synchronous and uninterruptible. Covered end-to-end in ",[19,805,807],{"href":806},"\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Fmoving-heavy-json-parsing-off-the-main-thread\u002F","moving heavy JSON parsing off the main thread",[795,809,810,813],{},[798,811,812],{},"Cryptography and hashing."," File checksums, client-side encryption, and password-derivation functions are pure CPU and trivially offloaded.",[795,815,816,819,820,823],{},[798,817,818],{},"Image processing."," Decoding, resizing, format conversion, and pixel manipulation via ",[33,821,822],{},"OffscreenCanvas"," belong in a worker; the canvas can be transferred so the worker paints without touching the DOM.",[795,825,826,829],{},[798,827,828],{},"Diffing and reconciliation."," Comparing two large object trees or computing a structural diff (document editors, sync engines) is CPU-bound and parallelizable.",[795,831,832,835],{},[798,833,834],{},"Search-index construction."," Building an inverted index or fuzzy-search structure over thousands of records is a classic load-time stall that a worker hides completely.",[15,837,838,839,842,843,846],{},"What does ",[28,840,841],{},"not"," belong in a worker: anything that reads or mutates the DOM (workers have no DOM), tiny operations where clone overhead exceeds compute, and work that is really I\u002FO-bound (a ",[33,844,845],{},"fetch"," does not block the main thread, so moving it to a worker buys nothing). Run the rule of thumb from step 2: if the function dominates a long task wider than the marshalling cost of its input, offload it.",[181,848,850],{"id":849},"_4-apply-the-fix-transferables-lifecycle-and-pooling","4. Apply the Fix: Transferables, Lifecycle, and Pooling",[852,853,855],"h3",{"id":854},"transferable-objects-move-bytes-instead-of-copying-them","Transferable objects: move bytes instead of copying them",[15,857,858,859,862,863,866,867,556,870,556,873,556,876,878,879,882,883,886],{},"By default every argument and return value is ",[28,860,861],{},"structured-cloned"," — a deep copy across the boundary. For large binary payloads that copy can itself cost tens of milliseconds. ",[798,864,865],{},"Transferable"," objects (",[33,868,869],{},"ArrayBuffer",[33,871,872],{},"MessagePort",[33,874,875],{},"ImageBitmap",[33,877,822],{},") are instead ",[28,880,881],{},"moved",": ownership transfers to the other thread in near-constant time and the sender loses access. Wrap them with ",[33,884,885],{},"Comlink.transfer()",":",[235,888,890],{"className":305,"code":889,"language":307,"meta":240,"style":240},"import * as Comlink from 'comlink';\n\nasync function hashFile(file) {\n  const buffer = await file.arrayBuffer();\n  \u002F\u002F Comlink.transfer marks the ArrayBuffer to be MOVED, not cloned — the 40MB\n  \u002F\u002F buffer crosses in ~0ms instead of being deep-copied.\n  const digest = await heavy.sha256(Comlink.transfer(buffer, [buffer]));\n  return digest;\n  \u002F\u002F trade-off: after transfer the local `buffer` is neutered (byteLength 0); if the\n  \u002F\u002F main thread still needs the bytes, clone first or you'll read an empty buffer.\n}\n",[33,891,892,908,912,930,952,957,962,987,995,1000,1005],{"__ignoreMap":240},[244,893,894,896,898,900,902,904,906],{"class":149,"line":246},[244,895,320],{"class":319},[244,897,324],{"class":323},[244,899,327],{"class":319},[244,901,331],{"class":330},[244,903,334],{"class":319},[244,905,337],{"class":272},[244,907,340],{"class":330},[244,909,910],{"class":149,"line":253},[244,911,346],{"emptyLinePlaceholder":345},[244,913,914,917,920,923,925,928],{"class":149,"line":259},[244,915,916],{"class":319},"async",[244,918,919],{"class":319}," function",[244,921,922],{"class":366}," hashFile",[244,924,370],{"class":330},[244,926,927],{"class":268},"file",[244,929,375],{"class":330},[244,931,932,935,938,940,943,946,949],{"class":149,"line":265},[244,933,934],{"class":319},"  const",[244,936,937],{"class":323}," buffer",[244,939,357],{"class":319},[244,941,942],{"class":319}," await",[244,944,945],{"class":330}," file.",[244,947,948],{"class":366},"arrayBuffer",[244,950,951],{"class":330},"();\n",[244,953,954],{"class":149,"line":363},[244,955,956],{"class":249},"  \u002F\u002F Comlink.transfer marks the ArrayBuffer to be MOVED, not cloned — the 40MB\n",[244,958,959],{"class":149,"line":378},[244,960,961],{"class":249},"  \u002F\u002F buffer crosses in ~0ms instead of being deep-copied.\n",[244,963,964,966,969,971,973,976,979,982,984],{"class":149,"line":399},[244,965,934],{"class":319},[244,967,968],{"class":323}," digest",[244,970,357],{"class":319},[244,972,942],{"class":319},[244,974,975],{"class":330}," heavy.",[244,977,978],{"class":366},"sha256",[244,980,981],{"class":330},"(Comlink.",[244,983,174],{"class":366},[244,985,986],{"class":330},"(buffer, [buffer]));\n",[244,988,989,992],{"class":149,"line":405},[244,990,991],{"class":319},"  return",[244,993,994],{"class":330}," digest;\n",[244,996,997],{"class":149,"line":418},[244,998,999],{"class":249},"  \u002F\u002F trade-off: after transfer the local `buffer` is neutered (byteLength 0); if the\n",[244,1001,1002],{"class":149,"line":424},[244,1003,1004],{"class":249},"  \u002F\u002F main thread still needs the bytes, clone first or you'll read an empty buffer.\n",[244,1006,1007],{"class":149,"line":435},[244,1008,1009],{"class":330},"}\n",[15,1011,1012,1013,1015,1016,1018],{},"Strings and plain objects are ",[28,1014,841],{}," transferable — they are always cloned. That is the core constraint behind the JSON case: passing a 4MB string to the worker still copies the string, so the win comes from moving the ",[33,1017,778],{}," CPU off-thread, not from avoiding the copy. When you control the wire format, prefer binary payloads you can transfer.",[852,1020,1022],{"id":1021},"lifecycle-and-pooling-dont-spawn-a-worker-per-call","Lifecycle and pooling: don't spawn a worker per call",[15,1024,1025,1026,1029],{},"Worker startup costs ",[33,1027,1028],{},"5–15ms"," and a fresh JS context, so creating one per call destroys the benefit. Create the worker once and reuse the proxy. For workloads that can run several jobs concurrently — independent diffs, a batch of image resizes — a small pool of workers lets jobs run in parallel across cores while bounding memory:",[235,1031,1033],{"className":305,"code":1032,"language":307,"meta":240,"style":240},"import * as Comlink from 'comlink';\n\nfunction createWorkerPool(size = navigator.hardwareConcurrency || 4) {\n  const workers = Array.from({ length: size }, () =>\n    Comlink.wrap(new Worker(new URL('.\u002Fheavy.worker.js', import.meta.url), { type: 'module' })),\n  );\n  let next = 0;\n  \u002F\u002F simple round-robin; good enough when jobs have similar cost\n  return () => workers[next++ % workers.length];\n  \u002F\u002F trade-off: each worker is a full JS context (~1-3MB RAM + its own copy of the\n  \u002F\u002F bundle's worker chunk), so a pool of 8 on a low-end phone can trigger memory\n  \u002F\u002F pressure — cap the size and shut idle workers down with worker.terminate().\n}\n\nconst pickWorker = createWorkerPool();\nexport const runDiff = (a, b) => pickWorker().diff(a, b);\n",[33,1034,1035,1051,1055,1081,1101,1140,1145,1161,1166,1193,1198,1203,1208,1212,1216,1229],{"__ignoreMap":240},[244,1036,1037,1039,1041,1043,1045,1047,1049],{"class":149,"line":246},[244,1038,320],{"class":319},[244,1040,324],{"class":323},[244,1042,327],{"class":319},[244,1044,331],{"class":330},[244,1046,334],{"class":319},[244,1048,337],{"class":272},[244,1050,340],{"class":330},[244,1052,1053],{"class":149,"line":253},[244,1054,346],{"emptyLinePlaceholder":345},[244,1056,1057,1060,1063,1065,1068,1070,1073,1076,1079],{"class":149,"line":259},[244,1058,1059],{"class":319},"function",[244,1061,1062],{"class":366}," createWorkerPool",[244,1064,370],{"class":330},[244,1066,1067],{"class":268},"size",[244,1069,357],{"class":319},[244,1071,1072],{"class":330}," navigator.hardwareConcurrency ",[244,1074,1075],{"class":319},"||",[244,1077,1078],{"class":323}," 4",[244,1080,375],{"class":330},[244,1082,1083,1085,1088,1090,1093,1095,1098],{"class":149,"line":265},[244,1084,934],{"class":319},[244,1086,1087],{"class":323}," workers",[244,1089,357],{"class":319},[244,1091,1092],{"class":330}," Array.",[244,1094,334],{"class":366},[244,1096,1097],{"class":330},"({ length: size }, () ",[244,1099,1100],{"class":319},"=>\n",[244,1102,1103,1106,1108,1110,1112,1114,1116,1118,1120,1122,1124,1126,1128,1130,1132,1135,1137],{"class":149,"line":363},[244,1104,1105],{"class":330},"    Comlink.",[244,1107,601],{"class":366},[244,1109,370],{"class":330},[244,1111,545],{"class":319},[244,1113,540],{"class":366},[244,1115,370],{"class":330},[244,1117,545],{"class":319},[244,1119,548],{"class":366},[244,1121,370],{"class":330},[244,1123,553],{"class":272},[244,1125,556],{"class":330},[244,1127,320],{"class":319},[244,1129,387],{"class":330},[244,1131,563],{"class":323},[244,1133,1134],{"class":330},".url), { type: ",[244,1136,574],{"class":272},[244,1138,1139],{"class":330}," })),\n",[244,1141,1142],{"class":149,"line":378},[244,1143,1144],{"class":330},"  );\n",[244,1146,1147,1150,1153,1156,1159],{"class":149,"line":399},[244,1148,1149],{"class":319},"  let",[244,1151,1152],{"class":330}," next ",[244,1154,1155],{"class":319},"=",[244,1157,1158],{"class":323}," 0",[244,1160,340],{"class":330},[244,1162,1163],{"class":149,"line":405},[244,1164,1165],{"class":249},"  \u002F\u002F simple round-robin; good enough when jobs have similar cost\n",[244,1167,1168,1170,1173,1175,1178,1181,1184,1187,1190],{"class":149,"line":418},[244,1169,991],{"class":319},[244,1171,1172],{"class":330}," () ",[244,1174,682],{"class":319},[244,1176,1177],{"class":330}," workers[next",[244,1179,1180],{"class":319},"++",[244,1182,1183],{"class":319}," %",[244,1185,1186],{"class":330}," workers.",[244,1188,1189],{"class":323},"length",[244,1191,1192],{"class":330},"];\n",[244,1194,1195],{"class":149,"line":424},[244,1196,1197],{"class":249},"  \u002F\u002F trade-off: each worker is a full JS context (~1-3MB RAM + its own copy of the\n",[244,1199,1200],{"class":149,"line":435},[244,1201,1202],{"class":249},"  \u002F\u002F bundle's worker chunk), so a pool of 8 on a low-end phone can trigger memory\n",[244,1204,1205],{"class":149,"line":440},[244,1206,1207],{"class":249},"  \u002F\u002F pressure — cap the size and shut idle workers down with worker.terminate().\n",[244,1209,1210],{"class":149,"line":446},[244,1211,1009],{"class":330},[244,1213,1214],{"class":149,"line":451},[244,1215,346],{"emptyLinePlaceholder":345},[244,1217,1218,1220,1223,1225,1227],{"class":149,"line":463},[244,1219,351],{"class":319},[244,1221,1222],{"class":323}," pickWorker",[244,1224,357],{"class":319},[244,1226,1062],{"class":366},[244,1228,951],{"class":330},[244,1230,1231,1233,1235,1238,1240,1243,1245,1247,1250,1253,1255,1257,1260,1263],{"class":149,"line":469},[244,1232,587],{"class":319},[244,1234,590],{"class":319},[244,1236,1237],{"class":366}," runDiff",[244,1239,357],{"class":319},[244,1241,1242],{"class":330}," (",[244,1244,19],{"class":268},[244,1246,556],{"class":330},[244,1248,1249],{"class":268},"b",[244,1251,1252],{"class":330},") ",[244,1254,682],{"class":319},[244,1256,1222],{"class":366},[244,1258,1259],{"class":330},"().",[244,1261,1262],{"class":366},"diff",[244,1264,1265],{"class":330},"(a, b);\n",[15,1267,1268,1269,1272,1273,1276],{},"For the cancellation discipline — aborting a stale worker job when the user re-interacts — apply the ",[33,1270,1271],{},"AbortController"," pattern from ",[19,1274,1275],{"href":198},"optimizing INP with scheduler.yield()","; a worker job that nobody will read should be abandoned so it stops consuming a core.",[181,1278,1280],{"id":1279},"deconstructing-the-cost-where-the-milliseconds-actually-go","Deconstructing the Cost: Where the Milliseconds Actually Go",[15,1282,1283,1284,1287,1288,1291],{},"A worker round-trip is not free, and pretending it is leads to offloading work that gets ",[28,1285,1286],{},"slower",". The total latency of ",[33,1289,1290],{},"await heavy.fn(input)"," decomposes into measurable phases, each with its own budget against the interaction:",[792,1293,1294,1311,1321,1327],{},[795,1295,1296,1299,1300,1303,1304,1306,1307,1310],{},[798,1297,1298],{},"Argument clone (post)"," — structured-cloning ",[33,1301,1302],{},"input"," into the message. Scales with payload size; effectively ",[33,1305,207],{}," for Transferables, but tens of milliseconds for a multi-megabyte plain object. Budget: keep under ",[33,1308,1309],{},"5ms"," by transferring binary or slimming the payload.",[795,1312,1313,1316,1317,1320],{},[798,1314,1315],{},"Dispatch + queue"," — the message crossing the boundary and the worker dequeuing it. Roughly ",[33,1318,1319],{},"1ms",", fixed.",[795,1322,1323,1326],{},[798,1324,1325],{},"Compute"," — the actual CPU work, now off the main thread. This is the entire point; it no longer counts against INP at all.",[795,1328,1329,1332,1333,1336],{},[798,1330,1331],{},"Result clone (return)"," — cloning the worker's return value back. The most-missed cost: a worker that parses JSON and returns the ",[28,1334,1335],{},"whole"," object pays a second deep clone on the way back. Budget: return a slim summary or a Transferable, not the full structure.",[15,1338,1339,1340,1342,1343,1346,1347,1349,1350,1353,1354,1357],{},"The failure pattern is symmetric copies: a handler that was ",[33,1341,35],{}," of CPU becomes ",[33,1344,1345],{},"40ms"," of argument clone + ",[33,1348,35],{}," off-thread compute + ",[33,1351,1352],{},"60ms"," of result clone, and the two clones — running on the main thread — reintroduce ",[33,1355,1356],{},"100ms"," of blocking you thought you removed. Measure the clone phases explicitly, not just the wall-clock of the call.",[181,1359,1361],{"id":1360},"advanced-diagnostics-and-edge-case-failure-modes","Advanced Diagnostics and Edge-Case Failure Modes",[15,1363,1364,1367,1368,1371],{},[798,1365,1366],{},"The result-clone trap."," The single most common regression is moving compute off-thread while leaving a giant return value to clone back. If the worker's job is to ",[28,1369,1370],{},"reduce"," data (parse-then-filter, index-then-query), do the reduction inside the worker and return only what the UI needs. Returning the full parsed object often clones more than the parse saved.",[15,1373,1374,1377,1378,1381,1382,1385],{},[798,1375,1376],{},"Exposing callbacks across the boundary."," Comlink can proxy functions you pass ",[28,1379,1380],{},"into"," the worker (",[33,1383,1384],{},"Comlink.proxy(cb)",") so the worker can call back for progress events. Each invocation is a full round-trip, so a progress callback fired per item floods the message queue and can block the main thread with reply handling — throttle progress to a few updates per second.",[15,1387,1388,1391,1392,1394],{},[798,1389,1390],{},"Module-worker support gaps."," If you must support a browser without module workers, the ",[33,1393,232],{}," form silently fails or falls back to classic-script semantics where bare imports break. Ship a classic-worker bundle behind feature detection rather than discovering the failure in the field.",[15,1396,1397,1400,1401,1404,1405,1408],{},[798,1398,1399],{},"Serialization of non-cloneable values."," Structured clone cannot copy functions, DOM nodes, class instances with methods, or ",[33,1402,1403],{},"Error"," subclasses with custom fields. Passing one throws a ",[33,1406,1407],{},"DataCloneError"," at the boundary. Keep the worker API surface to plain data and Transferables; if you need a class back, return its data and rehydrate on the main thread.",[15,1410,1411,1414],{},[798,1412,1413],{},"Debugging across threads."," A thrown error inside the worker surfaces on the main thread as a rejected promise, but the stack trace points into the worker bundle. Source-map the worker chunk in your build, and in DevTools select the worker's context in the Sources panel to set breakpoints — the main-thread debugger will not stop inside worker code by default.",[181,1416,1418],{"id":1417},"reference-implementations","Reference Implementations",[15,1420,1421],{},"These three patterns cover most production offloading work. Each is paste-ready and annotated with the configuration assumption, the trade-off, and the outcome you should observe.",[852,1423,1425],{"id":1424},"a-lazily-created-single-purpose-worker","A lazily created, single-purpose worker",[15,1427,1428],{},"Most apps need one worker created on first use, not at page load. This wrapper defers worker creation until the first call, so a feature nobody touches never pays startup cost, and memoizes the proxy thereafter.",[235,1430,1432],{"className":305,"code":1431,"language":307,"meta":240,"style":240},"import * as Comlink from 'comlink';\n\nlet proxy; \u002F\u002F memoized after first use\nexport function getHeavy() {\n  if (!proxy) {\n    const worker = new Worker(new URL('.\u002Fheavy.worker.js', import.meta.url), { type: 'module' });\n    proxy = Comlink.wrap(worker);\n  }\n  return proxy;\n}\n\u002F\u002F usage: const data = await getHeavy().parseJSON(text);\n\u002F\u002F trade-off: lazy creation adds ~5-15ms to the FIRST interaction that needs it; if the\n\u002F\u002F work is on a critical path the user hits immediately, create the worker eagerly during\n\u002F\u002F idle time with requestIdleCallback instead so the cost is paid before the click.\n",[33,1433,1434,1450,1454,1465,1477,1490,1528,1541,1546,1553,1557,1562,1567,1572],{"__ignoreMap":240},[244,1435,1436,1438,1440,1442,1444,1446,1448],{"class":149,"line":246},[244,1437,320],{"class":319},[244,1439,324],{"class":323},[244,1441,327],{"class":319},[244,1443,331],{"class":330},[244,1445,334],{"class":319},[244,1447,337],{"class":272},[244,1449,340],{"class":330},[244,1451,1452],{"class":149,"line":253},[244,1453,346],{"emptyLinePlaceholder":345},[244,1455,1456,1459,1462],{"class":149,"line":259},[244,1457,1458],{"class":319},"let",[244,1460,1461],{"class":330}," proxy; ",[244,1463,1464],{"class":249},"\u002F\u002F memoized after first use\n",[244,1466,1467,1469,1471,1474],{"class":149,"line":265},[244,1468,587],{"class":319},[244,1470,919],{"class":319},[244,1472,1473],{"class":366}," getHeavy",[244,1475,1476],{"class":330},"() {\n",[244,1478,1479,1482,1484,1487],{"class":149,"line":363},[244,1480,1481],{"class":319},"  if",[244,1483,1242],{"class":330},[244,1485,1486],{"class":319},"!",[244,1488,1489],{"class":330},"proxy) {\n",[244,1491,1492,1495,1497,1499,1501,1503,1505,1507,1509,1511,1513,1515,1517,1519,1521,1523,1525],{"class":149,"line":378},[244,1493,1494],{"class":319},"    const",[244,1496,532],{"class":323},[244,1498,357],{"class":319},[244,1500,537],{"class":319},[244,1502,540],{"class":366},[244,1504,370],{"class":330},[244,1506,545],{"class":319},[244,1508,548],{"class":366},[244,1510,370],{"class":330},[244,1512,553],{"class":272},[244,1514,556],{"class":330},[244,1516,320],{"class":319},[244,1518,387],{"class":330},[244,1520,563],{"class":323},[244,1522,1134],{"class":330},[244,1524,574],{"class":272},[244,1526,1527],{"class":330}," });\n",[244,1529,1530,1533,1535,1537,1539],{"class":149,"line":399},[244,1531,1532],{"class":330},"    proxy ",[244,1534,1155],{"class":319},[244,1536,598],{"class":330},[244,1538,601],{"class":366},[244,1540,604],{"class":330},[244,1542,1543],{"class":149,"line":405},[244,1544,1545],{"class":330},"  }\n",[244,1547,1548,1550],{"class":149,"line":418},[244,1549,991],{"class":319},[244,1551,1552],{"class":330}," proxy;\n",[244,1554,1555],{"class":149,"line":424},[244,1556,1009],{"class":330},[244,1558,1559],{"class":149,"line":435},[244,1560,1561],{"class":249},"\u002F\u002F usage: const data = await getHeavy().parseJSON(text);\n",[244,1563,1564],{"class":149,"line":440},[244,1565,1566],{"class":249},"\u002F\u002F trade-off: lazy creation adds ~5-15ms to the FIRST interaction that needs it; if the\n",[244,1568,1569],{"class":149,"line":446},[244,1570,1571],{"class":249},"\u002F\u002F work is on a critical path the user hits immediately, create the worker eagerly during\n",[244,1573,1574],{"class":149,"line":451},[244,1575,1576],{"class":249},"\u002F\u002F idle time with requestIdleCallback instead so the cost is paid before the click.\n",[15,1578,1579,1582,1583,1585],{},[798,1580,1581],{},"Outcome:"," zero worker startup cost on pages that never invoke the feature; one-time ",[33,1584,1028],{}," cost folded into the first use otherwise. This is the default shape for product code.",[852,1587,1589],{"id":1588},"transferring-an-offscreencanvas-for-image-work","Transferring an OffscreenCanvas for image work",[15,1591,1592,1593,1595,1596,1598,1599,387],{},"Image decode, resize, and re-encode are pure CPU and a textbook offload. ",[33,1594,822],{}," is transferable, so the worker can rasterize without ever touching the DOM and hand back an ",[33,1597,875],{}," or a ",[33,1600,1601],{},"Blob",[235,1603,1605],{"className":305,"code":1604,"language":307,"meta":240,"style":240},"\u002F\u002F main.js\nconst offscreen = canvasEl.transferControlToOffscreen();\n\u002F\u002F transfer ownership of the canvas to the worker; the main thread no longer draws to it\nawait getHeavy().renderThumbnail(Comlink.transfer(offscreen, [offscreen]), srcUrl);\n\u002F\u002F trade-off: once transferred, the main thread cannot draw to this canvas at all — only\n\u002F\u002F use transferControlToOffscreen for canvases the worker fully owns, not shared UI canvases.\n",[33,1606,1607,1612,1629,1634,1653,1658],{"__ignoreMap":240},[244,1608,1609],{"class":149,"line":246},[244,1610,1611],{"class":249},"\u002F\u002F main.js\n",[244,1613,1614,1616,1619,1621,1624,1627],{"class":149,"line":253},[244,1615,351],{"class":319},[244,1617,1618],{"class":323}," offscreen",[244,1620,357],{"class":319},[244,1622,1623],{"class":330}," canvasEl.",[244,1625,1626],{"class":366},"transferControlToOffscreen",[244,1628,951],{"class":330},[244,1630,1631],{"class":149,"line":259},[244,1632,1633],{"class":249},"\u002F\u002F transfer ownership of the canvas to the worker; the main thread no longer draws to it\n",[244,1635,1636,1639,1641,1643,1646,1648,1650],{"class":149,"line":265},[244,1637,1638],{"class":319},"await",[244,1640,1473],{"class":366},[244,1642,1259],{"class":330},[244,1644,1645],{"class":366},"renderThumbnail",[244,1647,981],{"class":330},[244,1649,174],{"class":366},[244,1651,1652],{"class":330},"(offscreen, [offscreen]), srcUrl);\n",[244,1654,1655],{"class":149,"line":363},[244,1656,1657],{"class":249},"\u002F\u002F trade-off: once transferred, the main thread cannot draw to this canvas at all — only\n",[244,1659,1660],{"class":149,"line":378},[244,1661,1662],{"class":249},"\u002F\u002F use transferControlToOffscreen for canvases the worker fully owns, not shared UI canvases.\n",[15,1664,1665,1667,1668,1671],{},[798,1666,1581],{}," image rasterization that previously blocked the main thread for ",[33,1669,1670],{},"80–250ms"," runs entirely off-thread; the visible long task disappears and INP for any concurrent interaction holds under budget.",[852,1673,1675],{"id":1674},"a-summarizing-worker-that-returns-only-what-the-ui-needs","A summarizing worker that returns only what the UI needs",[15,1677,1678],{},"The highest-leverage pattern is doing the reduction inside the worker so the return clone stays tiny. A worker that parses, filters, and projects returns kilobytes instead of megabytes.",[235,1680,1682],{"className":305,"code":1681,"language":307,"meta":240,"style":240},"\u002F\u002F heavy.worker.js\nComlink.expose({\n  topProducts(text, limit) {\n    const all = JSON.parse(text);                 \u002F\u002F heavy parse, off-thread\n    return all.items\n      .sort((a, b) => b.sales - a.sales)\n      .slice(0, limit)                            \u002F\u002F reduce BEFORE returning\n      .map(({ id, name, sales }) => ({ id, name, sales }));\n  },\n});\n\u002F\u002F trade-off: if the UI later needs the full dataset (e.g. for client-side re-sorting),\n\u002F\u002F returning only the top N forces a second worker round-trip — return a slim shape only\n\u002F\u002F when the UI genuinely consumes a slim shape.\n",[33,1683,1684,1688,1696,1712,1733,1740,1770,1787,1816,1820,1824,1829,1834],{"__ignoreMap":240},[244,1685,1686],{"class":149,"line":246},[244,1687,314],{"class":249},[244,1689,1690,1692,1694],{"class":149,"line":253},[244,1691,454],{"class":330},[244,1693,457],{"class":366},[244,1695,720],{"class":330},[244,1697,1698,1701,1703,1705,1707,1710],{"class":149,"line":259},[244,1699,1700],{"class":366},"  topProducts",[244,1702,370],{"class":330},[244,1704,90],{"class":268},[244,1706,556],{"class":330},[244,1708,1709],{"class":268},"limit",[244,1711,375],{"class":330},[244,1713,1714,1716,1719,1721,1723,1725,1727,1730],{"class":149,"line":265},[244,1715,1494],{"class":319},[244,1717,1718],{"class":323}," all",[244,1720,357],{"class":319},[244,1722,384],{"class":323},[244,1724,387],{"class":330},[244,1726,390],{"class":366},[244,1728,1729],{"class":330},"(text);                 ",[244,1731,1732],{"class":249},"\u002F\u002F heavy parse, off-thread\n",[244,1734,1735,1737],{"class":149,"line":363},[244,1736,381],{"class":319},[244,1738,1739],{"class":330}," all.items\n",[244,1741,1742,1745,1748,1751,1753,1755,1757,1759,1761,1764,1767],{"class":149,"line":378},[244,1743,1744],{"class":330},"      .",[244,1746,1747],{"class":366},"sort",[244,1749,1750],{"class":330},"((",[244,1752,19],{"class":268},[244,1754,556],{"class":330},[244,1756,1249],{"class":268},[244,1758,1252],{"class":330},[244,1760,682],{"class":319},[244,1762,1763],{"class":330}," b.sales ",[244,1765,1766],{"class":319},"-",[244,1768,1769],{"class":330}," a.sales)\n",[244,1771,1772,1774,1777,1779,1781,1784],{"class":149,"line":399},[244,1773,1744],{"class":330},[244,1775,1776],{"class":366},"slice",[244,1778,370],{"class":330},[244,1780,738],{"class":323},[244,1782,1783],{"class":330},", limit)                            ",[244,1785,1786],{"class":249},"\u002F\u002F reduce BEFORE returning\n",[244,1788,1789,1791,1794,1796,1799,1801,1804,1806,1809,1811,1813],{"class":149,"line":405},[244,1790,1744],{"class":330},[244,1792,1793],{"class":366},"map",[244,1795,668],{"class":330},[244,1797,1798],{"class":268},"id",[244,1800,556],{"class":330},[244,1802,1803],{"class":268},"name",[244,1805,556],{"class":330},[244,1807,1808],{"class":268},"sales",[244,1810,679],{"class":330},[244,1812,682],{"class":319},[244,1814,1815],{"class":330}," ({ id, name, sales }));\n",[244,1817,1818],{"class":149,"line":418},[244,1819,402],{"class":330},[244,1821,1822],{"class":149,"line":424},[244,1823,582],{"class":330},[244,1825,1826],{"class":149,"line":435},[244,1827,1828],{"class":249},"\u002F\u002F trade-off: if the UI later needs the full dataset (e.g. for client-side re-sorting),\n",[244,1830,1831],{"class":149,"line":440},[244,1832,1833],{"class":249},"\u002F\u002F returning only the top N forces a second worker round-trip — return a slim shape only\n",[244,1835,1836],{"class":149,"line":446},[244,1837,1838],{"class":249},"\u002F\u002F when the UI genuinely consumes a slim shape.\n",[15,1840,1841,1843],{},[798,1842,1581],{}," the return-clone phase drops from tens of milliseconds (cloning the whole parsed object) to near zero, eliminating the most common offloading regression where the result clone reintroduces the stall you removed.",[181,1845,1847],{"id":1846},"common-pitfalls","Common Pitfalls",[1849,1850,1851,1857,1866,1884,1896,1905,1911,1917],"ol",{},[795,1852,1853,1856],{},[798,1854,1855],{},"Returning the full parsed object."," The single most frequent regression — the worker parses off-thread but clones a multi-megabyte result back, reintroducing main-thread blocking. Reduce inside the worker.",[795,1858,1859,1862,1863,1865],{},[798,1860,1861],{},"Spawning a worker per call."," Worker startup is ",[33,1864,1028],{}," and a fresh context; creating one per invocation makes the abstraction slower than staying on the main thread. Reuse or pool.",[795,1867,1868,1875,1876,1879,1880,1883],{},[798,1869,1870,1871,1874],{},"Calling ",[33,1872,1873],{},".json()"," before the worker."," Using ",[33,1877,1878],{},"res.json()"," parses on the main thread before the payload ever reaches the worker. Fetch as ",[33,1881,1882],{},".text()"," and parse inside the worker.",[795,1885,1886,1889,1890,1892,1893,1895],{},[798,1887,1888],{},"Forgetting Transferables for binary data."," Passing a large ",[33,1891,869],{}," without ",[33,1894,885],{}," deep-copies it across the boundary; wrap it so it moves in near-constant time.",[795,1897,1898,1901,1902,1904],{},[798,1899,1900],{},"Offloading I\u002FO-bound work."," A ",[33,1903,845],{}," does not block the main thread, so moving it into a worker buys nothing and adds round-trip overhead. Offload CPU, not I\u002FO.",[795,1906,1907,1910],{},[798,1908,1909],{},"Over-fine-grained calls."," Because the Comlink proxy hides the boundary, it is easy to call it in a tight loop, paying clone + dispatch cost per iteration. Batch the work into one coarse call.",[795,1912,1913,1916],{},[798,1914,1915],{},"Missing worker source maps."," Without them, a worker exception surfaces as an unreadable rejected promise. Emit source maps for the worker chunk in your build.",[795,1918,1919,1922],{},[798,1920,1921],{},"Unbounded pools on low-end devices."," Each worker is a full JS context with its own copy of the worker bundle; a large pool on a budget phone triggers memory pressure. Cap pool size and terminate idle workers.",[181,1924,1926],{"id":1925},"validation-and-budgeting-in-ci","Validation and Budgeting in CI",[15,1928,1929],{},"Offloading is proven by the long-task profile and field INP after it ships, not by the architecture diagram. Assert both. In Lighthouse CI, fail the build when Total Blocking Time regresses, since TBT is the lab proxy that drops when the long task leaves the main thread:",[235,1931,1933],{"className":305,"code":1932,"language":307,"meta":240,"style":240},"\u002F\u002F lighthouserc.js\nmodule.exports = {\n  ci: {\n    assert: {\n      assertions: {\n        \u002F\u002F TBT should fall sharply once the heavy task runs off-thread\n        'total-blocking-time': ['error', { maxNumericValue: 200 }],\n        'mainthread-work-breakdown': ['warn', { maxNumericValue: 2000 }],\n      },\n    },\n  },\n};\n\u002F\u002F trade-off: TBT measures main-thread blocking only, so it will look great even if you\n\u002F\u002F introduced a 200ms result-clone stall that DOES block — pair it with a scripted\n\u002F\u002F interaction check that asserts no single task exceeds 50ms during the action.\n",[33,1934,1935,1940,1954,1959,1964,1969,1974,1993,2010,2015,2020,2024,2028,2033,2038],{"__ignoreMap":240},[244,1936,1937],{"class":149,"line":246},[244,1938,1939],{"class":249},"\u002F\u002F lighthouserc.js\n",[244,1941,1942,1945,1947,1950,1952],{"class":149,"line":253},[244,1943,1944],{"class":323},"module",[244,1946,387],{"class":330},[244,1948,1949],{"class":323},"exports",[244,1951,357],{"class":319},[244,1953,360],{"class":330},[244,1955,1956],{"class":149,"line":259},[244,1957,1958],{"class":330},"  ci: {\n",[244,1960,1961],{"class":149,"line":265},[244,1962,1963],{"class":330},"    assert: {\n",[244,1965,1966],{"class":149,"line":363},[244,1967,1968],{"class":330},"      assertions: {\n",[244,1970,1971],{"class":149,"line":378},[244,1972,1973],{"class":249},"        \u002F\u002F TBT should fall sharply once the heavy task runs off-thread\n",[244,1975,1976,1979,1982,1985,1988,1990],{"class":149,"line":399},[244,1977,1978],{"class":272},"        'total-blocking-time'",[244,1980,1981],{"class":330},": [",[244,1983,1984],{"class":272},"'error'",[244,1986,1987],{"class":330},", { maxNumericValue: ",[244,1989,161],{"class":323},[244,1991,1992],{"class":330}," }],\n",[244,1994,1995,1998,2000,2003,2005,2008],{"class":149,"line":405},[244,1996,1997],{"class":272},"        'mainthread-work-breakdown'",[244,1999,1981],{"class":330},[244,2001,2002],{"class":272},"'warn'",[244,2004,1987],{"class":330},[244,2006,2007],{"class":323},"2000",[244,2009,1992],{"class":330},[244,2011,2012],{"class":149,"line":418},[244,2013,2014],{"class":330},"      },\n",[244,2016,2017],{"class":149,"line":424},[244,2018,2019],{"class":330},"    },\n",[244,2021,2022],{"class":149,"line":435},[244,2023,402],{"class":330},[244,2025,2026],{"class":149,"line":440},[244,2027,443],{"class":330},[244,2029,2030],{"class":149,"line":446},[244,2031,2032],{"class":249},"\u002F\u002F trade-off: TBT measures main-thread blocking only, so it will look great even if you\n",[244,2034,2035],{"class":149,"line":451},[244,2036,2037],{"class":249},"\u002F\u002F introduced a 200ms result-clone stall that DOES block — pair it with a scripted\n",[244,2039,2040],{"class":149,"line":463},[244,2041,2042],{"class":249},"\u002F\u002F interaction check that asserts no single task exceeds 50ms during the action.\n",[15,2044,2045,2046,2049,2050,2053,2054,387],{},"Add a Playwright or Puppeteer script that performs the interaction while recording ",[33,2047,2048],{},"PerformanceLongTaskTiming",", and assert no entry exceeds ",[33,2051,2052],{},"50ms"," — this catches the result-clone regression that page-load TBT can miss, and it catches a worker that was never actually created (the work silently ran on the main thread because the bundler failed to emit the chunk). Run it in the same pipeline stage as Lighthouse. Finally, confirm field INP at the p75 in your RUM dashboard before and after; treat the deploy as proven only when the field number moves, since the marshalling cost is hardware-sensitive and low-end devices show it most. The lab gate blocks the regression from merging; the field check confirms the win on real phones. The full CI harness is covered in ",[19,2055,2057],{"href":2056},"\u002Fcore-web-vitals-measurement\u002Funderstanding-core-web-vitals-thresholds\u002Fbest-lighthouse-ci-setup-for-frontend-pipelines\u002F","the Lighthouse CI setup for frontend pipelines",[181,2059,2061],{"id":2060},"related","Related",[792,2063,2064,2070,2076,2083,2089],{},[795,2065,2066,2069],{},[19,2067,2068],{"href":198},"Optimizing INP with scheduler.yield()"," — the cheaper fix when work fits the budget but its shape is wrong; choose it before reaching for a worker.",[795,2071,2072,2075],{},[19,2073,2074],{"href":806},"Moving heavy JSON parsing off the main thread"," — the canonical parse-stall scenario diagnosed and fixed end to end.",[795,2077,2078,2082],{},[19,2079,2081],{"href":2080},"\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Fcomlink-vs-raw-postmessage-for-workers\u002F","Comlink vs raw postMessage for workers"," — the decision matrix for which worker abstraction to adopt.",[795,2084,2085,2088],{},[19,2086,2087],{"href":782},"Profiling event handlers for INP"," — locating and ranking the slow interaction before you decide what to offload.",[795,2090,2091,2094],{},[19,2092,2093],{"href":624},"Dynamic imports and route-based splitting"," — loading the worker chunk on the route that needs it instead of at first paint.",[2096,2097,2099],"script",{"type":2098},"application\u002Fld+json","\n{\n  \"@context\": \"https:\u002F\u002Fschema.org\",\n  \"@type\": \"HowTo\",\n  \"name\": \"Offloading Work to Web Workers with Comlink\",\n  \"description\": \"Move CPU-bound work off the main thread with Comlink RPC to keep Interaction to Next Paint under 200ms.\",\n  \"step\": [\n    { \"@type\": \"HowToStep\", \"name\": \"Environment setup: wiring Comlink through your bundler\", \"url\": \"https:\u002F\u002Ffrontend-performance.com\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002F#1-environment-setup-wiring-comlink-through-your-bundler\" },\n    { \"@type\": \"HowToStep\", \"name\": \"Capture a baseline and prove the work is the bottleneck\", \"url\": \"https:\u002F\u002Ffrontend-performance.com\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002F#2-capture-a-baseline-prove-the-work-is-the-bottleneck\" },\n    { \"@type\": \"HowToStep\", \"name\": \"Isolate what is worth offloading\", \"url\": \"https:\u002F\u002Ffrontend-performance.com\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002F#3-isolate-the-bottleneck-what-is-actually-worth-offloading\" },\n    { \"@type\": \"HowToStep\", \"name\": \"Apply the fix: transferables, lifecycle, and pooling\", \"url\": \"https:\u002F\u002Ffrontend-performance.com\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002F#4-apply-the-fix-transferables-lifecycle-and-pooling\" }\n  ]\n}\n",[2096,2101,2102],{"type":2098},"\n{\n  \"@context\": \"https:\u002F\u002Fschema.org\",\n  \"@type\": \"TechArticle\",\n  \"headline\": \"Offloading Work to Web Workers with Comlink: Protecting INP by Moving Heavy Work Off the Main Thread\",\n  \"description\": \"Use Comlink RPC to run JSON parsing, crypto, image work, and diffing in a Web Worker and hold INP under 200ms.\",\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\u002F\"\n}\n",[2096,2104,2105],{"type":2098},"\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  ]\n}\n",[2107,2108,2109],"style",{},"html pre.shiki code .sIIH1, html code.shiki .sIIH1{--shiki-default:#66707B;--shiki-dark:#66707B;--shiki-light:#66707B}html pre.shiki code .seIZK, html code.shiki .seIZK{--shiki-default:#702C00;--shiki-dark:#702C00;--shiki-light:#702C00}html pre.shiki code .s-_DF, html code.shiki .s-_DF{--shiki-default:#032563;--shiki-dark:#032563;--shiki-light:#032563}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);}html pre.shiki code .sP5qI, html code.shiki .sP5qI{--shiki-default:#A0111F;--shiki-dark:#A0111F;--shiki-light:#A0111F}html pre.shiki code .sf6mN, html code.shiki .sf6mN{--shiki-default:#023B95;--shiki-dark:#023B95;--shiki-light:#023B95}html pre.shiki code .syybb, html code.shiki .syybb{--shiki-default:#0E1116;--shiki-dark:#0E1116;--shiki-light:#0E1116}html pre.shiki code .ssM3C, html code.shiki .ssM3C{--shiki-default:#622CBC;--shiki-dark:#622CBC;--shiki-light:#622CBC}",{"title":240,"searchDepth":253,"depth":253,"links":2111},[2112,2113,2114,2115,2116,2117,2121,2122,2123,2128,2129,2130],{"id":183,"depth":253,"text":184},{"id":225,"depth":253,"text":226},{"id":290,"depth":253,"text":291},{"id":629,"depth":253,"text":630},{"id":786,"depth":253,"text":787},{"id":849,"depth":253,"text":850,"children":2118},[2119,2120],{"id":854,"depth":259,"text":855},{"id":1021,"depth":259,"text":1022},{"id":1279,"depth":253,"text":1280},{"id":1360,"depth":253,"text":1361},{"id":1417,"depth":253,"text":1418,"children":2124},[2125,2126,2127],{"id":1424,"depth":259,"text":1425},{"id":1588,"depth":259,"text":1589},{"id":1674,"depth":259,"text":1675},{"id":1846,"depth":253,"text":1847},{"id":1925,"depth":253,"text":1926},{"id":2060,"depth":253,"text":2061},"Use Comlink to run heavy work in a Web Worker over RPC and keep INP under 200ms.","md",{"slug":2134,"type":2135,"breadcrumb":2136,"datePublished":2143,"dateModified":2143},"offloading-work-to-web-workers-with-comlink","cluster",[2137,2140,2141],{"name":2138,"url":2139},"Home","\u002F",{"name":22,"url":21},{"name":5,"url":2142},"\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002F","2026-06-18","\u002Fcore-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink",{"title":5,"description":2146},"Move JSON parsing, crypto, image work, and diffing off the main thread with Comlink RPC to protect INP under 200ms. Transferables, worker pooling, and bundler setup.","core-web-vitals-measurement\u002Foffloading-work-to-web-workers-with-comlink\u002Findex","I3ZDMgRxg9Giow0wagjrryOnU37UIHHDTXpcrgnsHGo",[2150,2154],{"title":2151,"path":2152,"stem":2153,"children":-1},"Fix LCP Over 2.5s on React Apps","\u002Fcore-web-vitals-measurement\u002Fmeasuring-lcp-with-chrome-devtools\u002Fhow-to-fix-lcp-over-25-seconds-on-react-apps","core-web-vitals-measurement\u002Fmeasuring-lcp-with-chrome-devtools\u002Fhow-to-fix-lcp-over-25-seconds-on-react-apps\u002Findex",{"title":2155,"path":2156,"stem":2157,"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",1782237170909]