[{"data":1,"prerenderedAt":1882},["ShallowReactive",2],{"palette-posts":3,"post-\u002Fblog\u002Fthreads-that-hang":7},[4],{"title":5,"path":6},"Threaded WebAssembly doesn't crash. It hangs.","\u002Fblog\u002Fthreads-that-hang",{"id":8,"title":5,"body":9,"date":1869,"description":1870,"draft":1871,"extension":1872,"meta":1873,"navigation":176,"path":6,"seo":1874,"stem":1875,"tags":1876,"__hash__":1881},"blog\u002Fblog\u002Fthreads-that-hang.md",{"type":10,"value":11,"toc":1857},"minimark",[12,25,33,48,53,56,123,126,220,247,251,254,335,338,342,352,373,382,385,418,429,433,446,449,478,481,490,541,544,584,591,668,671,675,692,711,715,718,738,743,752,755,775,800,808,817,821,831,848,1160,1166,1170,1173,1740,1743,1746,1753,1757,1760,1763,1766,1769,1772,1807,1811,1845,1853],[13,14,15,16,20,21,24],"p",{},"The first time the threaded build of my solver failed, the app sat on \"Warming up the solver\" forever. The ",[17,18,19],"code",{},"try","\u002F",[17,22,23],{},"catch"," around the init never fired, and the spinner just kept spinning.",[13,26,27,28,32],{},"I found the cause and fixed it, but I still didn't know what ",[29,30,31],"em",{},"else"," could fail the same way. So I built a small test page, loaded the same Emscripten pthreads build in Chromium, Firefox and WebKit, and changed one header at a time. This post is what came out of it: what each failure looks like, why the promise never settles, a check that catches it in a few milliseconds, and numbers on whether the threads were worth it.",[13,34,35,36,43,44,47],{},"The build is tinyfem, the C++ finite element solver behind ",[37,38,42],"a",{"href":39,"rel":40},"https:\u002F\u002Felementarium.app",[41],"nofollow","Elementarium",", compiled with Emscripten 6.0.3. The header rules apply to any WebAssembly that uses threads, Rust with ",[17,45,46],{},"wasm-bindgen-rayon"," included. The exact way it hangs is Emscripten's.",[49,50,52],"h2",{"id":51},"what-a-threaded-build-needs","What a threaded build needs",[13,54,55],{},"A pthreads build only starts when all four of these hold:",[57,58,59,78,107,113],"ol",{},[60,61,62,69,70,73,74,77],"li",{},[63,64,65,66],"strong",{},"It's linked with ",[17,67,68],{},"-pthread"," (or ",[17,71,72],{},"-fopenmp",", which implies it). Its memory becomes a ",[17,75,76],{},"SharedArrayBuffer",", so the main thread and the workers can all see the same data.",[60,79,80,83,84,86,87,90,91,94,95,98,99,102,103,106],{},[63,81,82],{},"The page is cross-origin isolated."," Browsers only allow ",[17,85,76],{}," in this mode. You turn it on with two response headers on the HTML page: ",[17,88,89],{},"Cross-Origin-Opener-Policy"," (COOP) set to ",[17,92,93],{},"same-origin",", and ",[17,96,97],{},"Cross-Origin-Embedder-Policy"," (COEP) set to ",[17,100,101],{},"require-corp"," or ",[17,104,105],{},"credentialless",". More on choosing between those two below.",[60,108,109,112],{},[63,110,111],{},"The worker scripts are allowed to load."," Every thread is a Web Worker, and on an isolated page each worker's script needs its own COEP header.",[60,114,115,118,119,122],{},[63,116,117],{},"The thread pool can start."," Emscripten waits for its workers to load the module and report back before your ",[17,120,121],{},"await"," returns.",[13,124,125],{},"I build two versions from the same C++ sources: a threaded one and a plain single-threaded one to fall back to. The loader later in this post expects both to be ES modules with a default export, which these flags give you:",[127,128,133],"pre",{"className":129,"code":130,"language":131,"meta":132,"style":132},"language-bash shiki shiki-themes github-dark","# single-threaded\nemcc ... -O3 -sMODULARIZE -sEXPORT_ES6 -o public\u002Fsolver\u002Fserial\u002Fsolver.js\n\n# threaded\nemcc ... -O3 -sMODULARIZE -sEXPORT_ES6 -pthread \\\n  -sPTHREAD_POOL_SIZE=navigator.hardwareConcurrency \\\n  -o public\u002Fsolver\u002Fthreaded\u002Fsolver.js\n","bash","",[17,134,135,144,171,178,184,203,211],{"__ignoreMap":132},[136,137,140],"span",{"class":138,"line":139},"line",1,[136,141,143],{"class":142},"sAwPA","# single-threaded\n",[136,145,147,151,155,159,162,165,168],{"class":138,"line":146},2,[136,148,150],{"class":149},"svObZ","emcc",[136,152,154],{"class":153},"sU2Wk"," ...",[136,156,158],{"class":157},"sDLfK"," -O3",[136,160,161],{"class":157}," -sMODULARIZE",[136,163,164],{"class":157}," -sEXPORT_ES6",[136,166,167],{"class":157}," -o",[136,169,170],{"class":153}," public\u002Fsolver\u002Fserial\u002Fsolver.js\n",[136,172,174],{"class":138,"line":173},3,[136,175,177],{"emptyLinePlaceholder":176},true,"\n",[136,179,181],{"class":138,"line":180},4,[136,182,183],{"class":142},"# threaded\n",[136,185,187,189,191,193,195,197,200],{"class":138,"line":186},5,[136,188,150],{"class":149},[136,190,154],{"class":153},[136,192,158],{"class":157},[136,194,161],{"class":157},[136,196,164],{"class":157},[136,198,199],{"class":157}," -pthread",[136,201,202],{"class":157}," \\\n",[136,204,206,209],{"class":138,"line":205},6,[136,207,208],{"class":157},"  -sPTHREAD_POOL_SIZE=navigator.hardwareConcurrency",[136,210,202],{"class":157},[136,212,214,217],{"class":138,"line":213},7,[136,215,216],{"class":157},"  -o",[136,218,219],{"class":153}," public\u002Fsolver\u002Fthreaded\u002Fsolver.js\n",[13,221,222,223,226,227,230,231,234,235,238,239,242,243,246],{},"Each command writes a ",[17,224,225],{},"solver.js"," and a ",[17,228,229],{},"solver.wasm",". I keep them in the static ",[17,232,233],{},"public\u002F"," folder, where the bundler copies them as they are. That keeps the ",[17,236,237],{},".js",", the ",[17,240,241],{},".wasm"," and the probe script from later in this post next to each other, so one header rule on ",[17,244,245],{},"\u002Fsolver\u002F*"," covers all of them.",[49,248,250],{"id":249},"the-test","The test",[13,252,253],{},"I served the threaded build from a small Node server that sets headers per URL, and loaded it with Playwright in Chromium 151, Firefox 153 and WebKit 26.5. The page checks whether it's isolated, then waits up to 10 seconds for the module to load.",[255,256,257,276],"table",{},[258,259,260],"thead",{},[261,262,263,267,270,273],"tr",{},[264,265,266],"th",{},"Headers",[264,268,269],{},"Chromium",[264,271,272],{},"Firefox",[264,274,275],{},"WebKit (Safari's engine)",[277,278,279,292,306,321],"tbody",{},[261,280,281,285,288,290],{},[282,283,284],"td",{},"None",[282,286,287],{},"hangs",[282,289,287],{},[282,291,287],{},[261,293,294,300,302,304],{},[282,295,296,297,299],{},"COOP + COEP ",[17,298,105],{},", HTML only",[282,301,287],{},[282,303,287],{},[282,305,287],{},[261,307,308,313,316,319],{},[282,309,296,310,312],{},[17,311,105],{},", HTML and JS",[282,314,315],{},"ready, 169 ms",[282,317,318],{},"ready, 265 ms",[282,320,287],{},[261,322,323,327,330,333],{},[282,324,296,325,312],{},[17,326,101],{},[282,328,329],{},"ready, 154 ms",[282,331,332],{},"ready, 239 ms",[282,334,329],{},[13,336,337],{},"\"Hangs\" means the promise neither resolved nor rejected within 10 seconds. Three different causes are behind those seven hangs.",[49,339,341],{"id":340},"hang-1-the-page-isnt-isolated","Hang 1: the page isn't isolated",[13,343,344,345,347,348,351],{},"Without the headers there's no ",[17,346,76],{},". Emscripten still creates its workers, one per hardware thread, and then tries to send each one the shared memory. That ",[17,349,350],{},"postMessage"," throws:",[353,354,355,361,367],"ul",{},[60,356,357,358],{},"Chromium: ",[17,359,360],{},"SharedArrayBuffer transfer requires self.crossOriginIsolated.",[60,362,363,364],{},"Firefox: ",[17,365,366],{},"The WebAssembly.Memory object cannot be serialized.",[60,368,369,370],{},"WebKit: ",[17,371,372],{},"DataCloneError: The object can not be cloned.",[13,374,375,376,378,379,381],{},"That error does show up in the console, as an uncaught error. But it's thrown in a callback outside the init promise, so your ",[17,377,121],{}," never hears about it and your ",[17,380,23],{}," never runs. If it gets lost among other console messages, all you have left is a spinner.",[13,383,384],{},"So never load the threaded build on a page that can't run it:",[127,386,390],{"className":387,"code":388,"language":389,"meta":132,"style":132},"language-js shiki shiki-themes github-dark","if (!globalThis.crossOriginIsolated) {\n  \u002F\u002F load the single-threaded build instead\n}\n","js",[17,391,392,408,413],{"__ignoreMap":132},[136,393,394,398,402,405],{"class":138,"line":139},[136,395,397],{"class":396},"snl16","if",[136,399,401],{"class":400},"s95oV"," (",[136,403,404],{"class":396},"!",[136,406,407],{"class":400},"globalThis.crossOriginIsolated) {\n",[136,409,410],{"class":138,"line":146},[136,411,412],{"class":142},"  \u002F\u002F load the single-threaded build instead\n",[136,414,415],{"class":138,"line":173},[136,416,417],{"class":400},"}\n",[13,419,420,421,424,425,428],{},"Check ",[17,422,423],{},"crossOriginIsolated"," in the console on every environment you deploy to. Mine was ",[17,426,427],{},"false"," on my dev server for months. My security middleware used different COEP defaults in development and production, and production worked, so I never looked. Later I moved the headers into my framework's route config, where they looked right, and the middleware quietly overrode them. Now I check the response headers in the Network tab instead of trusting the config.",[49,430,432],{"id":431},"hang-2-the-page-is-isolated-the-workers-arent","Hang 2: the page is isolated, the workers aren't",[13,434,435,436,438,439,442,443,445],{},"This one took me the longest, because everything checks out. ",[17,437,423],{}," is ",[17,440,441],{},"true",", ",[17,444,76],{}," exists, and the init still hangs.",[13,447,448],{},"On an isolated page, a dedicated worker doesn't inherit COEP from the page. The browser checks the worker script's own response, and if that response has no compatible COEP header, the browser treats it as a network error, even for same-origin scripts. In Chromium you get:",[353,450,451,461,472],{},[60,452,453,456,457,460],{},[17,454,455],{},"(blocked:response)"," on the worker script in the Network tab, or ",[17,458,459],{},"net::ERR_BLOCKED_BY_RESPONSE",".",[60,462,463,464,467,468,471],{},"An ",[17,465,466],{},"error"," event on each ",[17,469,470],{},"Worker"," with an empty message, no filename and line 0.",[60,473,474,475,460],{},"One console line from Emscripten: ",[17,476,477],{},"worker sent an error! undefined:undefined: undefined",[13,479,480],{},"Firefox showed the same 16 empty error events. In both, the promise never settled, because Emscripten keeps waiting for workers that will never start.",[13,482,483,484,486,487,489],{},"You might think this only concerns the worker you wrote yourself. But Emscripten starts its threads from its own generated ",[17,485,237],{}," file, the one sitting next to the ",[17,488,241],{},":",[127,491,493],{"className":387,"code":492,"language":389,"meta":132,"style":132},"new Worker(new URL('solver.js', import.meta.url), { type: 'module', name: 'em-pthread' })\n",[17,494,495],{"__ignoreMap":132},[136,496,497,500,503,506,508,511,513,516,518,521,523,526,529,532,535,538],{"class":138,"line":139},[136,498,499],{"class":396},"new",[136,501,502],{"class":149}," Worker",[136,504,505],{"class":400},"(",[136,507,499],{"class":396},[136,509,510],{"class":149}," URL",[136,512,505],{"class":400},[136,514,515],{"class":153},"'solver.js'",[136,517,442],{"class":400},[136,519,520],{"class":396},"import",[136,522,460],{"class":400},[136,524,525],{"class":157},"meta",[136,527,528],{"class":400},".url), { type: ",[136,530,531],{"class":153},"'module'",[136,533,534],{"class":400},", name: ",[136,536,537],{"class":153},"'em-pthread'",[136,539,540],{"class":400}," })\n",[13,542,543],{},"Strictly, only scripts that run as workers need COEP. In practice it's simplest to send it on every JS file, so you can't miss one. This is the set that worked in all three engines:",[127,545,549],{"className":546,"code":547,"language":548,"meta":132,"style":132},"language-http shiki shiki-themes github-dark","# the HTML page\nCross-Origin-Opener-Policy: same-origin\nCross-Origin-Embedder-Policy: require-corp\n\n# every .js file (and the .wasm, it doesn't hurt)\nCross-Origin-Embedder-Policy: require-corp\nCross-Origin-Resource-Policy: same-origin\n","http",[17,550,551,556,561,566,570,575,579],{"__ignoreMap":132},[136,552,553],{"class":138,"line":139},[136,554,555],{},"# the HTML page\n",[136,557,558],{"class":138,"line":146},[136,559,560],{},"Cross-Origin-Opener-Policy: same-origin\n",[136,562,563],{"class":138,"line":173},[136,564,565],{},"Cross-Origin-Embedder-Policy: require-corp\n",[136,567,568],{"class":138,"line":180},[136,569,177],{"emptyLinePlaceholder":176},[136,571,572],{"class":138,"line":186},[136,573,574],{},"# every .js file (and the .wasm, it doesn't hurt)\n",[136,576,577],{"class":138,"line":205},[136,578,565],{},[136,580,581],{"class":138,"line":213},[136,582,583],{},"Cross-Origin-Resource-Policy: same-origin\n",[13,585,586,587,590],{},"Most frameworks and security middleware only add headers to HTML responses, so expect to configure the JS files separately. In my Nuxt app, the HTML headers come from ",[17,588,589],{},"nuxt-security",". In development, Vite serves the JS, so it needs its own copy:",[127,592,596],{"className":593,"code":594,"language":595,"meta":132,"style":132},"language-ts shiki shiki-themes github-dark","\u002F\u002F nuxt.config.ts (vite.config.ts works the same way)\nvite: {\n  server: {\n    headers: {\n      'Cross-Origin-Embedder-Policy': 'require-corp',\n      'Cross-Origin-Resource-Policy': 'same-origin',\n    },\n  },\n},\n","ts",[17,597,598,603,611,618,625,639,651,656,662],{"__ignoreMap":132},[136,599,600],{"class":138,"line":139},[136,601,602],{"class":142},"\u002F\u002F nuxt.config.ts (vite.config.ts works the same way)\n",[136,604,605,608],{"class":138,"line":146},[136,606,607],{"class":149},"vite",[136,609,610],{"class":400},": {\n",[136,612,613,616],{"class":138,"line":173},[136,614,615],{"class":149},"  server",[136,617,610],{"class":400},[136,619,620,623],{"class":138,"line":180},[136,621,622],{"class":149},"    headers",[136,624,610],{"class":400},[136,626,627,630,633,636],{"class":138,"line":186},[136,628,629],{"class":153},"      'Cross-Origin-Embedder-Policy'",[136,631,632],{"class":400},": ",[136,634,635],{"class":153},"'require-corp'",[136,637,638],{"class":400},",\n",[136,640,641,644,646,649],{"class":138,"line":205},[136,642,643],{"class":153},"      'Cross-Origin-Resource-Policy'",[136,645,632],{"class":400},[136,647,648],{"class":153},"'same-origin'",[136,650,638],{"class":400},[136,652,653],{"class":138,"line":213},[136,654,655],{"class":400},"    },\n",[136,657,659],{"class":138,"line":658},8,[136,660,661],{"class":400},"  },\n",[136,663,665],{"class":138,"line":664},9,[136,666,667],{"class":400},"},\n",[13,669,670],{},"In production my site is a static build, so the headers live in the host's config instead. Every server that hands out your JS needs checking: dev server, preview server, CDN.",[49,672,674],{"id":673},"hang-3-a-thread-nobody-can-start","Hang 3: a thread nobody can start",[13,676,677,678,683,684,687,688,691],{},"I haven't hit this one myself, but the ",[37,679,682],{"href":680,"rel":681},"https:\u002F\u002Femscripten.org\u002Fdocs\u002Fporting\u002Fpthreads.html",[41],"Emscripten docs"," are clear about it. A new worker can only start after the thread that asked for it returns to the event loop. If your code creates a thread and then waits for it without returning, for example ",[17,685,686],{},"pthread_create"," followed by ",[17,689,690],{},"pthread_join",", the worker never starts and the wait never ends.",[13,693,694,695,698,699,702,703,706,707,710],{},"That's what ",[17,696,697],{},"-sPTHREAD_POOL_SIZE"," in the build above prevents: it starts the workers before your code runs. The value can be a JavaScript expression, and ",[17,700,701],{},"navigator.hardwareConcurrency"," makes the pool big enough for any thread count you choose later. If your code has to block the browser's main thread, also look at ",[17,704,705],{},"-sPROXY_TO_PTHREAD",", which runs your ",[17,708,709],{},"main()"," in a worker.",[49,712,714],{"id":713},"safari-and-the-choice-between-coep-values","Safari and the choice between COEP values",[13,716,717],{},"COEP has two values that turn on isolation. They differ in how they treat resources from other origins, like images from a CDN:",[353,719,720,731],{},[60,721,722,726,727,730],{},[63,723,724],{},[17,725,101],{}," blocks any cross-origin resource that doesn't opt in, either by sending a ",[17,728,729],{},"Cross-Origin-Resource-Policy"," header or by being loaded with CORS.",[60,732,733,737],{},[63,734,735],{},[17,736,105],{}," loads those resources anyway, but without cookies.",[13,739,740,742],{},[17,741,105],{}," is much easier to adopt, because images from a CDN or a storage bucket keep working without changes. That's why I started with it.",[13,744,745,746,748,749,751],{},"But WebKit doesn't support ",[17,747,105],{},", on macOS or iOS. It ignores the header, the page isn't isolated, and every Safari user lands in hang 1. With the ",[17,750,423],{}," check in place, they quietly get the single-threaded build instead. Nothing breaks, so you won't notice unless you test in Safari. I only found out by running this test.",[13,753,754],{},"So the choice is:",[353,756,757,764],{},[60,758,759,763],{},[63,760,761],{},[17,762,105],{},": Chromium and Firefox get threads, Safari gets the single-threaded build, and your cross-origin images keep working.",[60,765,766,770,771,774],{},[63,767,768],{},[17,769,101],{},": all three get threads, but every cross-origin image, font and script must send CORP, or be loaded with ",[17,772,773],{},"crossorigin"," from a server that sends CORS headers.",[13,776,777,778,780,781,783,784,787,788,791,792,795,796,799],{},"If you choose ",[17,779,101],{},", watch out for components that load images themselves. My avatars broke only in the environment that sent ",[17,782,101],{},". The UI library preloaded each picture with ",[17,785,786],{},"new Image()"," and copied only the ",[17,789,790],{},"crossOrigin"," ",[29,793,794],{},"prop"," onto it, so the ",[17,797,798],{},"crossorigin=\"anonymous\""," attribute I'd put on the component never reached the request. The error says what was blocked, not which code requested it:",[127,801,806],{"className":802,"code":804,"language":805},[803],"language-text","ERR_BLOCKED_BY_RESPONSE.NotSameOriginAfterDefaultedToSameOriginByCoep\n","text",[17,807,804],{"__ignoreMap":132},[13,809,810,811,816],{},"If your host doesn't let you set headers at all (GitHub Pages, for example), ",[37,812,815],{"href":813,"rel":814},"https:\u002F\u002Fgithub.com\u002Fgzuidhof\u002Fcoi-serviceworker",[41],"coi-serviceworker"," adds them from a service worker. It needs one reload on the first visit to take effect.",[49,818,820],{"id":819},"detect-it-in-milliseconds","Detect it in milliseconds",[13,822,823,824,826,827,830],{},"Checking ",[17,825,423],{}," catches hang 1 and the Safari case, but not hang 2, because there the page ",[29,828,829],{},"is"," isolated. For that, I start a tiny test worker before loading anything big. It sits next to the solver files, so it gets the same headers:",[127,832,834],{"className":387,"code":833,"language":389,"meta":132,"style":132},"\u002F\u002F public\u002Fsolver\u002Fthreaded\u002Fprobe.js\npostMessage(self.crossOriginIsolated)\n",[17,835,836,841],{"__ignoreMap":132},[136,837,838],{"class":138,"line":139},[136,839,840],{"class":142},"\u002F\u002F public\u002Fsolver\u002Fthreaded\u002Fprobe.js\n",[136,842,843,845],{"class":138,"line":146},[136,844,350],{"class":149},[136,846,847],{"class":400},"(self.crossOriginIsolated)\n",[127,849,851],{"className":593,"code":850,"language":595,"meta":132,"style":132},"\u002F** Resolves true if a worker can start from `url` and is cross-origin isolated. *\u002F\nfunction canStartIsolatedWorker(url: string, timeoutMs = 3000): Promise\u003Cboolean> {\n  return new Promise((resolve) => {\n    let worker: Worker\n    try {\n      worker = new Worker(url, { type: 'module' })\n    }\n    catch {\n      \u002F\u002F e.g. a CSP worker-src rule: the constructor throws instead of firing `error`\n      return resolve(false)\n    }\n    const finish = (ok: boolean) => {\n      clearTimeout(timer)\n      worker.terminate()\n      resolve(ok)\n    }\n    const timer = setTimeout(() => finish(false), timeoutMs)\n    worker.onmessage = e => finish(e.data === true)\n    worker.onerror = () => finish(false)\n  })\n}\n",[17,852,853,858,905,930,943,950,969,974,981,986,1002,1007,1034,1043,1055,1064,1069,1096,1126,1149,1155],{"__ignoreMap":132},[136,854,855],{"class":138,"line":139},[136,856,857],{"class":142},"\u002F** Resolves true if a worker can start from `url` and is cross-origin isolated. *\u002F\n",[136,859,860,863,866,868,872,874,877,879,882,885,888,891,893,896,899,902],{"class":138,"line":146},[136,861,862],{"class":396},"function",[136,864,865],{"class":149}," canStartIsolatedWorker",[136,867,505],{"class":400},[136,869,871],{"class":870},"s9osk","url",[136,873,489],{"class":396},[136,875,876],{"class":157}," string",[136,878,442],{"class":400},[136,880,881],{"class":870},"timeoutMs",[136,883,884],{"class":396}," =",[136,886,887],{"class":157}," 3000",[136,889,890],{"class":400},")",[136,892,489],{"class":396},[136,894,895],{"class":149}," Promise",[136,897,898],{"class":400},"\u003C",[136,900,901],{"class":157},"boolean",[136,903,904],{"class":400},"> {\n",[136,906,907,910,913,915,918,921,924,927],{"class":138,"line":173},[136,908,909],{"class":396},"  return",[136,911,912],{"class":396}," new",[136,914,895],{"class":157},[136,916,917],{"class":400},"((",[136,919,920],{"class":870},"resolve",[136,922,923],{"class":400},") ",[136,925,926],{"class":396},"=>",[136,928,929],{"class":400}," {\n",[136,931,932,935,938,940],{"class":138,"line":180},[136,933,934],{"class":396},"    let",[136,936,937],{"class":400}," worker",[136,939,489],{"class":396},[136,941,942],{"class":149}," Worker\n",[136,944,945,948],{"class":138,"line":186},[136,946,947],{"class":396},"    try",[136,949,929],{"class":400},[136,951,952,955,958,960,962,965,967],{"class":138,"line":205},[136,953,954],{"class":400},"      worker ",[136,956,957],{"class":396},"=",[136,959,912],{"class":396},[136,961,502],{"class":149},[136,963,964],{"class":400},"(url, { type: ",[136,966,531],{"class":153},[136,968,540],{"class":400},[136,970,971],{"class":138,"line":213},[136,972,973],{"class":400},"    }\n",[136,975,976,979],{"class":138,"line":658},[136,977,978],{"class":396},"    catch",[136,980,929],{"class":400},[136,982,983],{"class":138,"line":664},[136,984,985],{"class":142},"      \u002F\u002F e.g. a CSP worker-src rule: the constructor throws instead of firing `error`\n",[136,987,989,992,995,997,999],{"class":138,"line":988},10,[136,990,991],{"class":396},"      return",[136,993,994],{"class":149}," resolve",[136,996,505],{"class":400},[136,998,427],{"class":157},[136,1000,1001],{"class":400},")\n",[136,1003,1005],{"class":138,"line":1004},11,[136,1006,973],{"class":400},[136,1008,1010,1013,1016,1018,1020,1023,1025,1028,1030,1032],{"class":138,"line":1009},12,[136,1011,1012],{"class":396},"    const",[136,1014,1015],{"class":149}," finish",[136,1017,884],{"class":396},[136,1019,401],{"class":400},[136,1021,1022],{"class":870},"ok",[136,1024,489],{"class":396},[136,1026,1027],{"class":157}," boolean",[136,1029,923],{"class":400},[136,1031,926],{"class":396},[136,1033,929],{"class":400},[136,1035,1037,1040],{"class":138,"line":1036},13,[136,1038,1039],{"class":149},"      clearTimeout",[136,1041,1042],{"class":400},"(timer)\n",[136,1044,1046,1049,1052],{"class":138,"line":1045},14,[136,1047,1048],{"class":400},"      worker.",[136,1050,1051],{"class":149},"terminate",[136,1053,1054],{"class":400},"()\n",[136,1056,1058,1061],{"class":138,"line":1057},15,[136,1059,1060],{"class":149},"      resolve",[136,1062,1063],{"class":400},"(ok)\n",[136,1065,1067],{"class":138,"line":1066},16,[136,1068,973],{"class":400},[136,1070,1072,1074,1077,1079,1082,1085,1087,1089,1091,1093],{"class":138,"line":1071},17,[136,1073,1012],{"class":396},[136,1075,1076],{"class":157}," timer",[136,1078,884],{"class":396},[136,1080,1081],{"class":149}," setTimeout",[136,1083,1084],{"class":400},"(() ",[136,1086,926],{"class":396},[136,1088,1015],{"class":149},[136,1090,505],{"class":400},[136,1092,427],{"class":157},[136,1094,1095],{"class":400},"), timeoutMs)\n",[136,1097,1099,1102,1105,1107,1110,1113,1115,1118,1121,1124],{"class":138,"line":1098},18,[136,1100,1101],{"class":400},"    worker.",[136,1103,1104],{"class":149},"onmessage",[136,1106,884],{"class":396},[136,1108,1109],{"class":870}," e",[136,1111,1112],{"class":396}," =>",[136,1114,1015],{"class":149},[136,1116,1117],{"class":400},"(e.data ",[136,1119,1120],{"class":396},"===",[136,1122,1123],{"class":157}," true",[136,1125,1001],{"class":400},[136,1127,1129,1131,1134,1136,1139,1141,1143,1145,1147],{"class":138,"line":1128},19,[136,1130,1101],{"class":400},[136,1132,1133],{"class":149},"onerror",[136,1135,884],{"class":396},[136,1137,1138],{"class":400}," () ",[136,1140,926],{"class":396},[136,1142,1015],{"class":149},[136,1144,505],{"class":400},[136,1146,427],{"class":157},[136,1148,1001],{"class":400},[136,1150,1152],{"class":138,"line":1151},20,[136,1153,1154],{"class":400},"  })\n",[136,1156,1158],{"class":138,"line":1157},21,[136,1159,417],{"class":400},[13,1161,1162,1163,1165],{},"On the same twelve setups, the probe gave the same answer as the full 10-second test every time, and it answered in 4 to 106 ms. A blocked worker fires ",[17,1164,466],{}," almost immediately, so you find out before downloading megabytes of wasm.",[49,1167,1169],{"id":1168},"the-loader","The loader",[13,1171,1172],{},"Putting it all together, the loader checks isolation, probes a worker, and only then loads the threaded build. That last step still gets a deadline, because a browser might refuse workers for a reason I haven't tested. Anything that goes wrong falls back to the single-threaded build:",[127,1174,1176],{"className":593,"code":1175,"language":595,"meta":132,"style":132},"const THREADED_INIT_DEADLINE_MS = 8000\n\n\u002F** Reject if `p` hasn't settled within `ms`. Never leaves the timer running. *\u002F\nasync function withDeadline\u003CT>(p: Promise\u003CT>, ms: number, label: string): Promise\u003CT> {\n  let timer: ReturnType\u003Ctypeof setTimeout> | undefined\n  try {\n    return await Promise.race([\n      p,\n      new Promise\u003Cnever>((_, reject) => {\n        timer = setTimeout(() => reject(new Error(`${label} did not settle within ${ms} ms`)), ms)\n      }),\n    ])\n  }\n  finally {\n    clearTimeout(timer)\n  }\n}\n\n\u002F\u002F files in public\u002F are served as-is, so tell the bundler not to touch these imports\nconst load = (path: string) => import(\u002F* @vite-ignore *\u002F path).then(m => m.default())\n\nexport async function loadSolver(): Promise\u003C{ module: any, threaded: boolean }> {\n  try {\n    if (!globalThis.crossOriginIsolated)\n      throw new Error('page is not cross-origin isolated. Check COOP\u002FCOEP on the HTML (Safari needs COEP: require-corp)')\n    if (!(await canStartIsolatedWorker('\u002Fsolver\u002Fthreaded\u002Fprobe.js')))\n      throw new Error('worker scripts are blocked. Look for (blocked:response) in the Network tab: a .js file is missing its COEP header')\n    const module = await withDeadline(load('\u002Fsolver\u002Fthreaded\u002Fsolver.js'), THREADED_INIT_DEADLINE_MS, 'threaded init')\n    return { module, threaded: true }\n  }\n  catch (err) {\n    console.warn('[solver] Using the single-threaded build:', err instanceof Error ? err.message : err)\n  }\n  return { module: await load('\u002Fsolver\u002Fserial\u002Fsolver.js'), threaded: false }\n}\n",[17,1177,1178,1191,1195,1200,1261,1287,1294,1312,1317,1346,1387,1392,1397,1402,1409,1416,1420,1424,1428,1433,1485,1489,1533,1540,1553,1570,1593,1609,1646,1659,1664,1673,1706,1711,1735],{"__ignoreMap":132},[136,1179,1180,1183,1186,1188],{"class":138,"line":139},[136,1181,1182],{"class":396},"const",[136,1184,1185],{"class":157}," THREADED_INIT_DEADLINE_MS",[136,1187,884],{"class":396},[136,1189,1190],{"class":157}," 8000\n",[136,1192,1193],{"class":138,"line":146},[136,1194,177],{"emptyLinePlaceholder":176},[136,1196,1197],{"class":138,"line":173},[136,1198,1199],{"class":142},"\u002F** Reject if `p` hasn't settled within `ms`. Never leaves the timer running. *\u002F\n",[136,1201,1202,1205,1208,1211,1213,1216,1219,1221,1223,1225,1227,1229,1232,1235,1237,1240,1242,1245,1247,1249,1251,1253,1255,1257,1259],{"class":138,"line":180},[136,1203,1204],{"class":396},"async",[136,1206,1207],{"class":396}," function",[136,1209,1210],{"class":149}," withDeadline",[136,1212,898],{"class":400},[136,1214,1215],{"class":149},"T",[136,1217,1218],{"class":400},">(",[136,1220,13],{"class":870},[136,1222,489],{"class":396},[136,1224,895],{"class":149},[136,1226,898],{"class":400},[136,1228,1215],{"class":149},[136,1230,1231],{"class":400},">, ",[136,1233,1234],{"class":870},"ms",[136,1236,489],{"class":396},[136,1238,1239],{"class":157}," number",[136,1241,442],{"class":400},[136,1243,1244],{"class":870},"label",[136,1246,489],{"class":396},[136,1248,876],{"class":157},[136,1250,890],{"class":400},[136,1252,489],{"class":396},[136,1254,895],{"class":149},[136,1256,898],{"class":400},[136,1258,1215],{"class":149},[136,1260,904],{"class":400},[136,1262,1263,1266,1268,1270,1273,1275,1278,1281,1284],{"class":138,"line":186},[136,1264,1265],{"class":396},"  let",[136,1267,1076],{"class":400},[136,1269,489],{"class":396},[136,1271,1272],{"class":149}," ReturnType",[136,1274,898],{"class":400},[136,1276,1277],{"class":396},"typeof",[136,1279,1280],{"class":400}," setTimeout> ",[136,1282,1283],{"class":396},"|",[136,1285,1286],{"class":157}," undefined\n",[136,1288,1289,1292],{"class":138,"line":205},[136,1290,1291],{"class":396},"  try",[136,1293,929],{"class":400},[136,1295,1296,1299,1302,1304,1306,1309],{"class":138,"line":213},[136,1297,1298],{"class":396},"    return",[136,1300,1301],{"class":396}," await",[136,1303,895],{"class":157},[136,1305,460],{"class":400},[136,1307,1308],{"class":149},"race",[136,1310,1311],{"class":400},"([\n",[136,1313,1314],{"class":138,"line":658},[136,1315,1316],{"class":400},"      p,\n",[136,1318,1319,1322,1324,1326,1329,1332,1335,1337,1340,1342,1344],{"class":138,"line":664},[136,1320,1321],{"class":396},"      new",[136,1323,895],{"class":157},[136,1325,898],{"class":400},[136,1327,1328],{"class":157},"never",[136,1330,1331],{"class":400},">((",[136,1333,1334],{"class":870},"_",[136,1336,442],{"class":400},[136,1338,1339],{"class":870},"reject",[136,1341,923],{"class":400},[136,1343,926],{"class":396},[136,1345,929],{"class":400},[136,1347,1348,1351,1353,1355,1357,1359,1362,1364,1366,1369,1371,1374,1376,1379,1381,1384],{"class":138,"line":988},[136,1349,1350],{"class":400},"        timer ",[136,1352,957],{"class":396},[136,1354,1081],{"class":149},[136,1356,1084],{"class":400},[136,1358,926],{"class":396},[136,1360,1361],{"class":149}," reject",[136,1363,505],{"class":400},[136,1365,499],{"class":396},[136,1367,1368],{"class":149}," Error",[136,1370,505],{"class":400},[136,1372,1373],{"class":153},"`${",[136,1375,1244],{"class":400},[136,1377,1378],{"class":153},"} did not settle within ${",[136,1380,1234],{"class":400},[136,1382,1383],{"class":153},"} ms`",[136,1385,1386],{"class":400},")), ms)\n",[136,1388,1389],{"class":138,"line":1004},[136,1390,1391],{"class":400},"      }),\n",[136,1393,1394],{"class":138,"line":1009},[136,1395,1396],{"class":400},"    ])\n",[136,1398,1399],{"class":138,"line":1036},[136,1400,1401],{"class":400},"  }\n",[136,1403,1404,1407],{"class":138,"line":1045},[136,1405,1406],{"class":396},"  finally",[136,1408,929],{"class":400},[136,1410,1411,1414],{"class":138,"line":1057},[136,1412,1413],{"class":149},"    clearTimeout",[136,1415,1042],{"class":400},[136,1417,1418],{"class":138,"line":1066},[136,1419,1401],{"class":400},[136,1421,1422],{"class":138,"line":1071},[136,1423,417],{"class":400},[136,1425,1426],{"class":138,"line":1098},[136,1427,177],{"emptyLinePlaceholder":176},[136,1429,1430],{"class":138,"line":1128},[136,1431,1432],{"class":142},"\u002F\u002F files in public\u002F are served as-is, so tell the bundler not to touch these imports\n",[136,1434,1435,1437,1440,1442,1444,1447,1449,1451,1453,1455,1458,1460,1463,1466,1469,1471,1474,1476,1479,1482],{"class":138,"line":1151},[136,1436,1182],{"class":396},[136,1438,1439],{"class":149}," load",[136,1441,884],{"class":396},[136,1443,401],{"class":400},[136,1445,1446],{"class":870},"path",[136,1448,489],{"class":396},[136,1450,876],{"class":157},[136,1452,923],{"class":400},[136,1454,926],{"class":396},[136,1456,1457],{"class":149}," import",[136,1459,505],{"class":400},[136,1461,1462],{"class":142},"\u002F* @vite-ignore *\u002F",[136,1464,1465],{"class":400}," path).",[136,1467,1468],{"class":149},"then",[136,1470,505],{"class":400},[136,1472,1473],{"class":870},"m",[136,1475,1112],{"class":396},[136,1477,1478],{"class":400}," m.",[136,1480,1481],{"class":149},"default",[136,1483,1484],{"class":400},"())\n",[136,1486,1487],{"class":138,"line":1157},[136,1488,177],{"emptyLinePlaceholder":176},[136,1490,1492,1495,1498,1500,1503,1506,1508,1510,1513,1516,1518,1521,1523,1526,1528,1530],{"class":138,"line":1491},22,[136,1493,1494],{"class":396},"export",[136,1496,1497],{"class":396}," async",[136,1499,1207],{"class":396},[136,1501,1502],{"class":149}," loadSolver",[136,1504,1505],{"class":400},"()",[136,1507,489],{"class":396},[136,1509,895],{"class":149},[136,1511,1512],{"class":400},"\u003C{ ",[136,1514,1515],{"class":870},"module",[136,1517,489],{"class":396},[136,1519,1520],{"class":157}," any",[136,1522,442],{"class":400},[136,1524,1525],{"class":870},"threaded",[136,1527,489],{"class":396},[136,1529,1027],{"class":157},[136,1531,1532],{"class":400}," }> {\n",[136,1534,1536,1538],{"class":138,"line":1535},23,[136,1537,1291],{"class":396},[136,1539,929],{"class":400},[136,1541,1543,1546,1548,1550],{"class":138,"line":1542},24,[136,1544,1545],{"class":396},"    if",[136,1547,401],{"class":400},[136,1549,404],{"class":396},[136,1551,1552],{"class":400},"globalThis.crossOriginIsolated)\n",[136,1554,1556,1559,1561,1563,1565,1568],{"class":138,"line":1555},25,[136,1557,1558],{"class":396},"      throw",[136,1560,912],{"class":396},[136,1562,1368],{"class":149},[136,1564,505],{"class":400},[136,1566,1567],{"class":153},"'page is not cross-origin isolated. Check COOP\u002FCOEP on the HTML (Safari needs COEP: require-corp)'",[136,1569,1001],{"class":400},[136,1571,1573,1575,1577,1579,1581,1583,1585,1587,1590],{"class":138,"line":1572},26,[136,1574,1545],{"class":396},[136,1576,401],{"class":400},[136,1578,404],{"class":396},[136,1580,505],{"class":400},[136,1582,121],{"class":396},[136,1584,865],{"class":149},[136,1586,505],{"class":400},[136,1588,1589],{"class":153},"'\u002Fsolver\u002Fthreaded\u002Fprobe.js'",[136,1591,1592],{"class":400},")))\n",[136,1594,1596,1598,1600,1602,1604,1607],{"class":138,"line":1595},27,[136,1597,1558],{"class":396},[136,1599,912],{"class":396},[136,1601,1368],{"class":149},[136,1603,505],{"class":400},[136,1605,1606],{"class":153},"'worker scripts are blocked. Look for (blocked:response) in the Network tab: a .js file is missing its COEP header'",[136,1608,1001],{"class":400},[136,1610,1612,1614,1617,1619,1621,1623,1625,1628,1630,1633,1636,1639,1641,1644],{"class":138,"line":1611},28,[136,1613,1012],{"class":396},[136,1615,1616],{"class":157}," module",[136,1618,884],{"class":396},[136,1620,1301],{"class":396},[136,1622,1210],{"class":149},[136,1624,505],{"class":400},[136,1626,1627],{"class":149},"load",[136,1629,505],{"class":400},[136,1631,1632],{"class":153},"'\u002Fsolver\u002Fthreaded\u002Fsolver.js'",[136,1634,1635],{"class":400},"), ",[136,1637,1638],{"class":157},"THREADED_INIT_DEADLINE_MS",[136,1640,442],{"class":400},[136,1642,1643],{"class":153},"'threaded init'",[136,1645,1001],{"class":400},[136,1647,1649,1651,1654,1656],{"class":138,"line":1648},29,[136,1650,1298],{"class":396},[136,1652,1653],{"class":400}," { module, threaded: ",[136,1655,441],{"class":157},[136,1657,1658],{"class":400}," }\n",[136,1660,1662],{"class":138,"line":1661},30,[136,1663,1401],{"class":400},[136,1665,1667,1670],{"class":138,"line":1666},31,[136,1668,1669],{"class":396},"  catch",[136,1671,1672],{"class":400}," (err) {\n",[136,1674,1676,1679,1682,1684,1687,1690,1693,1695,1698,1701,1703],{"class":138,"line":1675},32,[136,1677,1678],{"class":400},"    console.",[136,1680,1681],{"class":149},"warn",[136,1683,505],{"class":400},[136,1685,1686],{"class":153},"'[solver] Using the single-threaded build:'",[136,1688,1689],{"class":400},", err ",[136,1691,1692],{"class":396},"instanceof",[136,1694,1368],{"class":149},[136,1696,1697],{"class":396}," ?",[136,1699,1700],{"class":400}," err.message ",[136,1702,489],{"class":396},[136,1704,1705],{"class":400}," err)\n",[136,1707,1709],{"class":138,"line":1708},33,[136,1710,1401],{"class":400},[136,1712,1714,1716,1719,1721,1723,1725,1728,1731,1733],{"class":138,"line":1713},34,[136,1715,909],{"class":396},[136,1717,1718],{"class":400}," { module: ",[136,1720,121],{"class":396},[136,1722,1439],{"class":149},[136,1724,505],{"class":400},[136,1726,1727],{"class":153},"'\u002Fsolver\u002Fserial\u002Fsolver.js'",[136,1729,1730],{"class":400},"), threaded: ",[136,1732,427],{"class":157},[136,1734,1658],{"class":400},[136,1736,1738],{"class":138,"line":1737},35,[136,1739,417],{"class":400},[13,1741,1742],{},"The warnings are long on purpose. Nothing in my UI shows whether a solve ran on one thread or eight, so without the warning a fallback just looks like a slow app. With it, whoever opens the console next is told exactly where to look.",[13,1744,1745],{},"With the two checks in front, the deadline should never fire. The build is normally ready in 150 to 270 ms, so 8 seconds leaves room for slow devices. If it does fire, the abandoned attempt may leave idle workers behind. I accept that, because it should be rare and a reload clears it.",[13,1747,1748,1749,1752],{},"Because the two builds are separate files behind dynamic ",[17,1750,1751],{},"import()",", users on the fallback never download the threaded build. If you have several workers, send them all through the same loader so they pick the same build, and the second one comes from the HTTP cache.",[49,1754,1756],{"id":1755},"was-it-worth-it","Was it worth it?",[13,1758,1759],{},"Here's the speedup on a real workload: a shell plate with about 48,000 unknowns, solved with conjugate gradients inside a Web Worker, which is how my app runs it. Each point is the best of five runs, alternating between the two builds:",[1761,1762],"thread-scaling",{},[13,1764,1765],{},"Yes, but less than the core count suggests. Two threads give 1.6×, four give 2.25×, and then the curve flattens. Anything from 5 to 13 threads sits between 2.3× and 2.44×. After that it falls, and with all 16 hardware threads it's down to 1.43×.",[13,1767,1768],{},"My reading of why, which I haven't proven: most of the solve is conjugate gradient iterations, and each one is mostly a sparse matrix-vector product. That reads the whole matrix from memory and does very little arithmetic per number, so after a few threads they're all waiting on the same memory bandwidth. Past 8, the extra threads don't bring new cores, they share the existing ones. At 16 they also compete with the browser's own threads.",[13,1770,1771],{},"A few practical things came out of these runs:",[353,1773,1774,1780,1789,1795,1801],{},[60,1775,1776,1779],{},[63,1777,1778],{},"Don't trust the default thread count."," In all three engines, OpenMP started with 4 threads, whatever the machine had. Here that gets you 2.25× out of a possible 2.44×, which is fine. On a different machine or workload it may not be.",[60,1781,1782,1788],{},[63,1783,1784,1785,1787],{},"Don't use ",[17,1786,701],{}," as the thread count."," It counts hardware threads, not cores. On this laptop it says 16, and 16 is one of the slowest settings. Half of it is a reasonable starting point on machines with two threads per core. Then measure, and let users change it.",[60,1790,1791,1794],{},[63,1792,1793],{},"Keep heavy threaded work off the page's main thread."," I first ran the same benchmark on the main thread, and at 16 threads it was 0.58×, slower than not using threads at all. In a worker, the same setting gave 1.43×.",[60,1796,1797,1800],{},[63,1798,1799],{},"Use the single-threaded build when you only want one thread."," On one thread the threaded build was 3% slower than the plain one.",[60,1802,1803,1806],{},[63,1804,1805],{},"Check that the answers match."," A race condition in parallel code can give a slightly different result instead of crashing. I compare the threaded result with the single-threaded one in every run, and here they're bit-for-bit identical. They won't always be, because adding numbers in a different order can change the last digits. If they're not identical, decide up front how close counts as correct.",[49,1808,1810],{"id":1809},"checklist","Checklist",[57,1812,1813,1816,1819,1828,1833,1839,1842],{},[60,1814,1815],{},"Build a single-threaded version too, and keep both builds' files together in a static folder.",[60,1817,1818],{},"Send COOP and COEP on the HTML, and COEP and CORP on every JS file. Confirm in the Network tab, on every environment.",[60,1820,1821,1822,1824,1825,1827],{},"Use ",[17,1823,101],{}," if Safari users should get threads. Use ",[17,1826,105],{}," if your cross-origin images make that too hard, and accept the single-threaded build on Safari.",[60,1829,420,1830,1832],{},[17,1831,423],{},", then probe a worker, before loading the threaded build.",[60,1834,1835,1836,460],{},"Pre-size the thread pool with ",[17,1837,1838],{},"PTHREAD_POOL_SIZE",[60,1840,1841],{},"Never await the threaded init without a deadline, and warn loudly when you fall back.",[60,1843,1844],{},"Choose the thread count yourself, and measure it on your own workload.",[13,1846,1847,1848,1852],{},"If you've hit a failure that isn't here, I'd like to hear about it: ",[37,1849,1851],{"href":1850},"mailto:jan@vorisek.me","jan@vorisek.me",". I'll add it to the post.",[1854,1855,1856],"style",{},"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 pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":132,"searchDepth":146,"depth":146,"links":1858},[1859,1860,1861,1862,1863,1864,1865,1866,1867,1868],{"id":51,"depth":146,"text":52},{"id":249,"depth":146,"text":250},{"id":340,"depth":146,"text":341},{"id":431,"depth":146,"text":432},{"id":673,"depth":146,"text":674},{"id":713,"depth":146,"text":714},{"id":819,"depth":146,"text":820},{"id":1168,"depth":146,"text":1169},{"id":1755,"depth":146,"text":1756},{"id":1809,"depth":146,"text":1810},"2026-10-08","I loaded an Emscripten pthreads build in Chromium, Firefox and WebKit under every isolation-header setup. Seven of twelve hung, and not one rejected. Why each one hangs, how to detect it in milliseconds, and a loader that falls back instead of freezing.",false,"md",{},{"title":5,"description":1870},"blog\u002Fthreads-that-hang",[1877,1878,1879,1880],"wasm","emscripten","pthreads","performance","8-2M5BZBUjwx9fRiu5qvcWzIkRNh_2GbaGwzWa1ghMA",1791464838304]