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);
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.
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.
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.
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.