Emscripten pthreads vs Manual Worker Orchestration

A C or Rust library that already uses threads can be compiled with its threading intact, or compiled single-threaded and run several times over partitioned data. Both use Web Workers underneath; they differ in who owns the memory, who owns the scheduling, and what the deployment has to look like.

This comparison belongs to WebAssembly in Workers, part of High-Performance Computation Patterns.


What Each One Actually Is

Emscripten pthreads compiles pthread_create to a real Web Worker. The module’s linear memory is a shared WebAssembly.Memory, so every thread addresses the same bytes and C-level mutexes, atomics and condition variables work as written. Emscripten pre-spawns a pool of workers at startup (PTHREAD_POOL_SIZE) because creating one is asynchronous while pthread_create is not.

emcc solver.c -o solver.js \
  -pthread -sPTHREAD_POOL_SIZE=4 \
  -sINITIAL_MEMORY=256MB -sALLOW_MEMORY_GROWTH=0

Manual orchestration compiles the module single-threaded and instantiates it once per worker. Each instance has private linear memory; the page partitions the input, sends each worker its slice, and combines the results.

// page.ts — N independent instances, no shared state anywhere
const pool = Array.from({ length: 4 }, () =>
  new Worker(new URL('./solver.worker.ts', import.meta.url), { type: 'module' }));

const parts = splitRange(0, rows, pool.length);
const results = await Promise.all(pool.map((worker, i) => run(worker, parts[i])));
const combined = merge(results);
Memory topology of a pthreads build versus independent instances Two topologies. In the pthreads build, one shared WebAssembly memory of 256 megabytes is addressed by four worker threads, so any thread can read any address and C-level mutexes work as written; the whole arrangement requires cross-origin isolation. In the manual build, four workers each hold their own module instance with its own 64-megabyte linear memory, totalling 256 megabytes with no sharing, no isolation requirement, and combination performed by the page through messages. Who owns the bytes pthreads — one shared heap shared WebAssembly.Memory — 256 MB backed by a SharedArrayBuffer thread thread thread thread mutexes and atomics work as written · isolation headers mandatory manual — four private heaps instance 64 MB instance 64 MB instance 64 MB instance 64 MB no sharing · no isolation headers · the page combines results Total memory is similar. What differs is whether one thread can see another's data — and therefore whether the algorithm can be written as if it were on a desktop.
The right-hand topology is not a weaker version of the left — it is the one that ships without changing the deployment.

The Trade-Offs

Emscripten pthreads Manual orchestration
Source changes none — threads compile as written must partition; drop shared-state algorithms
Isolation headers required not required
Memory one heap, sized for the whole job N heaps; input duplicated if not partitionable
Cross-thread data direct addressing, zero copy messages, with clone or transfer
Scheduling the C code’s own yours, in JavaScript
Debugging thread state hidden inside the module one instance per worker, inspectable
Failure blast radius a segfault-equivalent takes the whole heap one worker; the rest continue
Startup pre-spawn the pool; heavier instantiate per worker, lazily

Two rows deserve emphasis. Isolation headers are frequently the deciding factor: enabling them means auditing every third-party embed — analytics, maps, video, chat — because a single non-compliant subresource silently turns isolation off, and then the pthreads build does not instantiate at all. Teams often discover this after the port.

Blast radius matters in production. In a pthreads build, memory corruption in one thread is corruption of the one heap every thread shares, and the usual outcome is a stuck or crashed module for the whole feature. With independent instances, a bad partition kills one worker; the supervisor restarts it and the others are unaffected — the recovery pattern in Restarting Crashed Workers with Exponential Backoff.

The isolation requirement is not negotiable for pthreads

Serve Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp on every document that loads the module, and make every cross-origin subresource CORP- or CORS-eligible. Verify crossOriginIsolated === true at runtime rather than trusting the deployment — a CDN that strips headers on one route produces a feature that works locally and fails in production with an instantiation error that does not mention headers.


When Each One Wins

Choose pthreads when porting an existing threaded codebase whose algorithm genuinely needs shared state — a solver with a shared work-stealing queue, a physics step with cross-partition interactions, anything where partitioning would change the results. Also choose it when the input is large and duplicating it per instance would not fit in memory.

Choose manual orchestration when the work partitions cleanly — per image, per tile, per record batch, per frame — and when you cannot or will not enable isolation. This covers most browser workloads: image processing, per-item transforms, independent simulations, format conversion.

The tell is the algorithm, not the language. If a job can be described as “run this function on each of these N pieces and combine the answers”, it does not need shared memory and should not pay for it.


The Middle Ground

Both can coexist, and the combination is often better than either. Compile single-threaded, orchestrate in JavaScript, and use a shared buffer only for the data that genuinely must be shared:

// A read-only dataset shared by every instance, without pthreads
const shared = new SharedArrayBuffer(dataset.byteLength);
new Uint8Array(shared).set(new Uint8Array(dataset));

for (const worker of pool) {
  worker.postMessage({ kind: 'INIT', shared });     // one copy of the data, N instances
}

Each worker copies the region it needs into its own linear memory, or — with a module built to accept an external memory — reads it directly. Memory duplication drops to the working set rather than the whole dataset, isolation headers are still required for the SharedArrayBuffer itself, and the C code stays single-threaded and simple. The mechanics are covered in Sharing WebAssembly Linear Memory Across Workers.


Migrating From One to the Other

Teams usually start with whichever the upstream project provides and later discover it does not fit the deployment. Both directions are tractable, and the work is mostly the same in each.

From pthreads to manual. Build with -sUSE_PTHREADS=0 and replace the threaded entry point with a single-threaded one that takes a range. The parallelism moves to JavaScript: the page splits the input, dispatches to a pool, and merges. What has to be checked is the merge — sums and counts combine trivially, but anything order-dependent or stateful across the whole input needs rethinking, and that is where the real work is. Budget for the algorithm review, not for the build change.

From manual to pthreads. Rebuild with -pthread and add the isolation headers, then confirm crossOriginIsolated in every environment, including preview deployments and any embedded context. The failure mode when a header is missing is total — the module does not instantiate — so add a runtime check that produces a clear message rather than a WebAssembly error, and keep the single-threaded build available as a fallback path.

Whichever direction, keep both builds compiling in CI for a release or two. A single build flag is cheap to maintain and gives an immediate answer when a user reports that the feature does not work on a site that embeds you, or on a browser where isolation is unavailable:

const threaded = crossOriginIsolated && typeof SharedArrayBuffer === 'function';
const module = await import(threaded ? './solver-mt.js' : './solver-st.js');

That branch is four lines and removes an entire class of support ticket, since the degraded path is slower rather than absent.


Which build fits which workload Two lists. A pthreads build fits a ported codebase with shared state, a solver with a work-stealing queue, a physics step with cross-partition interactions, and an input too large to duplicate per instance. Manual orchestration fits work that partitions per item, deployments that cannot enable cross-origin isolation, embeds inside third-party pages, and any case where one failing partition should not take the whole job down. Choosing by the shape of the work pthreads — shared heap, isolation required manual — private heaps, no headers · a ported codebase whose threads already work · a solver with a shared work-stealing queue · a physics step with cross-partition interaction · an input too large to duplicate per instance · fine-grained synchronisation between threads · work that partitions per image, tile or record · a deployment that cannot enable isolation · embeds inside third-party pages · one bad partition should not kill the job · instances you can restart independently Both use Web Workers underneath. The difference is who owns the memory and the deployment.
The left column is about the algorithm; the right column is usually about the deployment — and the deployment wins more often than teams expect.

Gotchas

ALLOW_MEMORY_GROWTH with pthreads. Growing a shared memory invalidates every TypedArray view over it in every thread; Emscripten handles this internally but any JavaScript views you cached are stale afterwards. Prefer a fixed INITIAL_MEMORY large enough for the job.

Blocking the main thread with pthread_join. Emscripten’s main thread is the browser’s main thread unless the module runs in a worker (-sPROXY_TO_PTHREAD). A join on the main thread deadlocks, because the thread it waits for cannot be created until the event loop turns.

Pool size versus core count. PTHREAD_POOL_SIZE pre-spawns workers at startup, and each costs memory whether used or not. Sizing it to navigator.hardwareConcurrency on a 16-core machine spawns 16 threads for a job that may use two.

Instantiating the module per request. Compiling a 2 MB module costs 60–150 ms. Compile once with WebAssembly.compile, then post the resulting WebAssembly.Module to each worker — it is structured-cloneable and instantiating from it takes a few milliseconds.

Assuming SIMD and threads travel together. They are separate features with separate build flags and separate support. A build that requires both narrows the audience twice.


Measuring speed-up against the wrong baseline. Comparing a four-thread WebAssembly build against the JavaScript implementation it replaced conflates two changes. Measure the single-threaded WebAssembly build first: it often provides most of the improvement, and the threading may be adding complexity for the last 20%.

Forgetting that the browser’s main thread counts. On a four-core machine, four worker threads plus the main thread oversubscribe the CPU, and the main thread is the one that has to stay responsive. Size the pool to hardwareConcurrency - 1 for sustained work.


Wall clock for a 900 ms solve, by parallelism strategy Four measurements of the same 900 millisecond single-threaded solve. Running it single-threaded takes 900 milliseconds. Four independent instances over partitioned data take 310 milliseconds, a 2.9 times speed-up, with the shortfall coming from partition and merge overhead. A pthreads build with four threads takes 265 milliseconds, a 3.4 times speed-up. Eight pthreads on a four-core machine take 250 milliseconds, showing that oversubscription buys almost nothing. Same solve, four strategies single-threaded 900 ms — the baseline 4 instances, partitioned 310 ms · 2.9× · no isolation needed 4 pthreads, shared heap 265 ms · 3.4× · isolation required 8 pthreads on 4 cores 250 ms — oversubscription adds nothing 0 300 600 900 ms The gap between the second and third bars is the price of not needing isolation headers.
Forty-five milliseconds is what partitioning costs — usually less than the deployment audit it avoids.

Performance Note

A 900 ms single-threaded solve on a 2023 laptop, run four ways: pthreads with four threads finished in 265 ms (3.4× speed-up, near-linear) and required isolation headers plus a 256 MB shared heap. Four independent instances over partitioned data finished in 310 ms (2.9×) with 4 × 64 MB of private heaps and no header changes. The 45 ms difference is the partition-and-merge overhead — a reasonable price for a deployment that did not need auditing, and the wrong trade only when the algorithm cannot be partitioned at all.

The summary that survives most projects: partition in JavaScript when the work partitions, and reach for shared-memory threading only when the algorithm genuinely refuses to be split — then accept the deployment requirements that come with it.

Whichever route a project takes, keep the single-threaded build in continuous integration: it is the fallback that keeps the feature usable wherever cross-origin isolation cannot be arranged.

Frequently Asked Questions

Do Emscripten pthreads require cross-origin isolation?
Yes, without exception. A pthreads build uses a shared WebAssembly.Memory, which is backed by a SharedArrayBuffer, so the document must serve Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp and every cross-origin subresource must be CORP- or CORS-eligible. Without isolation the module fails to instantiate — there is no degraded mode.
Can I avoid isolation headers and still use several cores?
Yes: instantiate the module independently in each worker and partition the data yourself. Each instance has its own linear memory, so nothing is shared and no headers are needed. The cost is memory — N copies of the heap — and that any cross-partition combination has to happen through messages rather than shared state.

See also