Threading and workers
Two independent switches get confused constantly. runtime: 'st' | 'mt' is a build-time choice about threads inside the wasm module. useWorker is a runtime choice about which JavaScript thread the module lives on. You can use either, both, or neither.
The two axes
| runtime: 'st' | runtime: 'mt' | |
|---|---|---|
useWorker: false | the default: wasm on the main thread, smallest setup | pthreads via SharedArrayBuffer on the main thread - needs COOP/COEP |
useWorker: true | wasm in one Web Worker, main thread stays free; required for OPFS | wasm in a worker, pthreads spawned from there - COOP/COEP plus Worker support |
| You want | Pick |
|---|---|
| The quickest path to C++ in the browser | st, no worker |
| Persistent storage in the browser | st + useWorker: true |
| CPU-bound parallelism (image, geo, crypto) | mt |
| Both persistence and parallelism | mt + useWorker: true |
| Cloudflare Workers, Deno Deploy, Vercel Edge | st only - neither is supported |
| React Native | mt when performance matters; no host configuration needed |
Turning on multithreading
The wasm is then compiled with -pthread. Note that this promotes in one direction only: if any dependency is mt, your project becomes mt too - you cannot downgrade a multithreaded library back to single-threaded.
The COOP/COEP requirement
Multithreaded wasm needs SharedArrayBuffer, which browsers gate behind cross-origin isolation. Your host has to send two response headers:
Without them SharedArrayBuffer is undefined and init fails quietly. Check it from the console on the deployed page:
| Host | Where the headers go |
|---|---|
| Vite dev / preview | injected by @cpp.js/plugin-vite |
| Webpack / Rspack dev server | injected by @cpp.js/plugin-webpack |
| Vercel | the headers array in vercel.json |
| Netlify, Cloudflare Pages | a _headers file |
| nginx | add_header Cross-Origin-Opener-Policy same-origin; and the COEP twin |
| Express / custom server | middleware setting both on every response |
Cross-Origin-Resource-Policy: cross-origin. Either switch to Cross-Origin-Embedder-Policy: credentialless, or proxy those assets through your own origin.What changes with useWorker
The module moves into a dedicated Web Worker and the main thread talks to a bridged proxy. It looks the same; it behaves asynchronously.
| Aspect | Without worker | With worker |
|---|---|---|
m.add(2, 3) returns | 5 | Promise<5> |
new X(...) returns | the instance | a promise - write await new X(...) |
m.FS.writeFile(...) returns | undefined | a promise |
| Synchronous callbacks into JS | work | do not - design them as promise round-trips |
| OPFS storage | throws | works, when the browser supports it |
| Shutting down | n/a | init.terminate() kills the worker |
Set dts: 'promise' in cppjs.config.js so the generated TypeScript matches. Embind objects such as vectors are proxied automatically; m.toArray() and m.toVector() keep working.
Where it does not apply
- Edge runtimes (Cloudflare Workers, Deno Deploy, Vercel Edge) have no Worker constructor and no
SharedArrayBuffer:stonly, in-memory filesystem only. - React Native routes pthreads through JSI, so
mtneeds no headers and no isolation.useWorkeris meaningless there. - Node.js runs
mtwithout any host configuration.
Pitfalls
- `mt` works in dev but not in production. The dev plugin injected the headers; your host is not. Check
crossOriginIsolated. - Mixing `mt` and `st` artifacts in one bundle. Incompatible memory layouts - the loader fails with a streaming-compile error. Rebuild from clean after switching.
- Assuming `mt` implies `useWorker`. It does not;
mtwithout a worker runs pthreads from the main thread. - Sync-style code against a worker runtime.
instance.method is not a functionusually means construction returned a promise you never awaited.