Multi-threaded WebAssembly in production, and the parts that hang
Shipping a C++ FEM solver with OpenMP threads to the browser. Isolation headers, nested workers, and why the fallback needs a deadline.
Elementarium's solver is C++ built on Eigen, compiled with Emscripten. It ships as two WebAssembly builds of the same sources: a default single-threaded one, and a -pthread one exported as tinyfem/parallel. The second exists for one reason: it's the only way Eigen's OpenMP paths get real threads. In the default build omp_get_max_threads() is 1, so every #pragma omp parallel for runs serially.
Getting the threaded build to run in a browser takes three things, and each one fails differently.
1. Cross-origin isolation, or no SharedArrayBuffer
Threads need SharedArrayBuffer, and browsers only hand that to a page that is cross-origin isolated:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless
credentialless rather than require-corp was a deliberate choice. Project thumbnails load as plain <img> tags from an object store that sends no Cross-Origin-Resource-Policy. Under require-corp they'd go blank. credentialless isolates the page just as well, and simply re-fetches such no-cors subresources without credentials.
Two traps on the Nuxt side:
nuxt-securitymerges route-rule headers underneath its own config, so a COOP written inrouteRulesis silently overridden. COOP belongs insecurity.headers.- Its header plugin only hooks the HTML response. COEP and CORP also have to be on the asset responses, because each worker script must itself be served with COEP or Chrome refuses to start it on an isolated page. Its defaults also differ by environment (
unsafe-nonein dev), which is whycrossOriginIsolatedwas never true on the dev server until the values were spelled out.
2. Nested workers
Isolation is necessary but not sufficient. The threaded build spawns its own pool of nested workers, and that is a second thing a given browser can refuse.
3. Failures that don't throw
This is the nasty one. When the browser refuses a pthread worker, it fires an error event with no message, and Emscripten's init promise then never settles. A bare await leaves the UI on "warming up" forever, with a clean console.
So the init never trusts the threaded build. It races it against a deadline (it normally reports ready in about 250 ms; the deadline is 8 s) and falls back to the serial build on any failure:
if (prefer === 'parallel' && globalThis.crossOriginIsolated) {
try {
const { init } = await import('tinyfem/parallel')
const module = await withDeadline(init(), 8000)
return { module, build: 'parallel', ...configureThreads(module, threads) }
}
catch (err) {
console.warn('[tinyfem] Threaded build unavailable, falling back', err)
}
}
const { init } = await import('tinyfem')
const module = await init()
return { module, build: 'single', ...configureThreads(module, 0) }
A slow solve beats a dead one. The warning is loud on purpose: nothing else in the UI distinguishes a threaded solve from a serial one, so without it the degradation is invisible.
Smaller things that bit
- Read the ceiling before you cap.
get_omp_max_threads()returns the current setting, not the hardware limit. Read it first, then callset_omp_num_threads, or the real number is gone. - One decision for every worker. The solver worker and the mesh worker both go through the same init. The builds are separate ~4 MB binaries; picking different ones per worker would put both on the wire instead of letting the second hit the HTTP cache.
- Static hosting needs its own headers. None of this reaches the browser from
nuxt generateoutput unless the host sets COEP and CORP for/_nuxt/**as well.