← Notes

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-security merges route-rule headers underneath its own config, so a COOP written in routeRules is silently overridden. COOP belongs in security.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-none in dev), which is why crossOriginIsolated was 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 call set_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 generate output unless the host sets COEP and CORP for /_nuxt/** as well.