Threading — runtime: 'st' vs 'mt', useWorker, and edge limits
Two orthogonal axes: threading (single vs multi-thread Wasm) and
useWorker(whether the Wasm module runs in a Web Worker). Don't confuse them.
The two axes
│ runtime: 'st' │ runtime: 'mt'
────────────────────┼──────────────────────┼─────────────────────────
useWorker: false │ Default. Main thread │ Wasm runs main-thread,
│ Wasm. Smallest setup.│ pthreads via SharedArray-
│ │ Buffer. Needs COOP/COEP.
────────────────────┼──────────────────────┼─────────────────────────
useWorker: true │ Wasm in 1 Web Worker.│ Wasm in 1 Web Worker;
│ Comlink bridge. Main │ pthreads spawn ADDITIONAL
│ thread free. Required│ workers from there. Needs
│ for OPFS persistence.│ COOP/COEP + Worker support.
When you need each
| You want | Pick |
|---|---|
| Quickest path to "C++ in browser" | runtime: 'st', no useWorker |
| Persistent storage in browser | runtime: 'st', useWorker: true |
| CPU-bound parallelism (image / geo / crypto) | runtime: 'mt' |
| Both: persistent storage AND parallelism | runtime: 'mt', useWorker: true |
| Cloudflare Worker / Deno Deploy / Vercel Edge | runtime: 'st' only — mt and useWorker not supported |
| React Native | runtime: 'mt' if perf-sensitive (pthreads via JSI; no COOP/COEP needed) |
Setting runtime: 'mt'
In cppjs.config.js:
export default {
general: { name: 'myapp' },
paths: { config: import.meta.url },
target: { runtime: 'mt' }, // ← here
}
Two things happen at build time:
- The Wasm is compiled with
-pthread(Emscripten flag). - Any transitive dependency that's already
mtkeeps the project onmt. Conversely, if any dep ismt, this project auto-promotes tomt(you can't downgrade).
The COOP/COEP requirement
Multi-threaded Wasm uses SharedArrayBuffer, which browsers gate behind cross-origin isolation. Your hosting layer must send these response headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Without them, SharedArrayBuffer is undefined and the Wasm init silently fails.
How to verify (in the browser console)
console.log(crossOriginIsolated) // must be true for `mt`
console.log(typeof SharedArrayBuffer) // must be 'function'
Per-host configuration
| Host | Config |
|---|---|
| Vite dev / preview | Auto-injected by @cpp.js/plugin-vite |
| Webpack / Rspack dev server | Auto-injected by @cpp.js/plugin-webpack |
| Vercel | Add to vercel.json: { "headers": [{ "source": "/(.*)", "headers": [{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin" }, { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }] }] } |
| Netlify | Add to _headers: /*\n Cross-Origin-Opener-Policy: same-origin\n Cross-Origin-Embedder-Policy: require-corp |
| Cloudflare Pages | _headers file (same syntax as Netlify) |
| nginx | add_header Cross-Origin-Opener-Policy same-origin; add_header Cross-Origin-Embedder-Policy require-corp; |
| Express / Next.js custom server | Set headers via middleware on every response |
COEP gotcha
require-corp blocks cross-origin resources unless they explicitly opt in (Cross-Origin-Resource-Policy: cross-origin on the response, or crossorigin attribute on <img> / <script> tags). If your page loads third-party images / fonts / scripts, you'll either need to:
- Switch to
Cross-Origin-Embedder-Policy: credentialless(more permissive, supported in Chrome 96+, Firefox 110+). - Or proxy third-party assets through your own origin.
useWorker: true (independent of threading)
Wasm runs in a single dedicated Web Worker; main thread receives a Comlink-bridged proxy.
const m = await initCppJs({ useWorker: true })
// m looks identical, but every call is async.
const result = await m.add(2, 3)
You want this when:
- You need OPFS persistent storage (mandatory; OPFS is Worker-scope-only).
- Your C++ is slow and you don't want to block the main thread paint loop.
- You're using
runtime: 'mt'and want pthread workers spawned from a non-main scope (cleaner architecture).
You don't need it when:
- The C++ is fast (sub-frame) and main-thread blocking doesn't matter.
- You're already using
mtfor parallelism (pthread workers are separate fromuseWorker).
What changes when useWorker: true
| Aspect | Without worker | With worker |
|---|---|---|
m.add(2, 3) returns | 5 | Promise<5> |
m.FS.writeFile(...) returns | undefined | Promise<undefined> |
| Synchronous callbacks | Work | Don't work — use returned promises |
| OPFS storage | Throws | Works (if browser supports) |
| Termination | n/a | initCppJs.terminate() kills the worker |
Embind objects (vectors, structs) are auto-proxied via cpp.js's custom Comlink transfer handlers. m.toArray(vec) and m.toVector(cls, arr) work transparently.
Edge runtime limits (Cloudflare Workers, Deno Deploy, Vercel Edge)
These platforms run JavaScript in V8 isolates that don't expose the Web Worker API. Therefore:
- ❌
useWorker: true— fails (no Worker constructor). - ❌
runtime: 'mt'— fails (pthreads need workers + SharedArrayBuffer). - ❌ OPFS — fails (browser-only API anyway).
- ✅
runtime: 'st'+ memory fs — works.
If your use case demands persistence on edge, you need an external service (R2, KV, S3) — call it from JS and feed bytes into Wasm via m.FS.writeFile(...).
React Native
Pthreads are routed through JSI (no SharedArrayBuffer, no COOP/COEP). runtime: 'mt' works without any host configuration. useWorker is a no-op (n/a — no Web Worker API in RN).
Common pitfalls
mtworks in dev but not in prod — the bundler plugin injects COOP/COEP for dev/preview, but production hosting needs explicit configuration. Look atcrossOriginIsolatedin console; iffalse, the headers are missing.- Mixing
mtandstartifacts in one bundle — they have incompatible memory layouts. The CLI prevents this at build time but if you manually copy.wasmfiles between projects, you'll see "wasm streaming compile failed" errors. - Calling sync callbacks across
useWorker: true— Comlink can't invoke main-thread sync code from worker. If your C++ needs a JS callback, design it as a promise round-trip. - Assuming
runtime: 'mt'enablesuseWorker— they're independent.runtime: 'mt'withoutuseWorkerruns pthreads on the main thread;useWorker: truewithoutruntime: 'mt'runs single-thread Wasm in a worker. - Loading third-party scripts on a COEP page —
require-corpblocks them unless they sendCross-Origin-Resource-Policy: cross-origin. Switch tocredentiallessor proxy.
See also
init.md—useWorker,runtime(viacppjs.config.js),getWasmFunction.cppjs-config.md—target.runtime: 'st' | 'mt'.filesystem.md— why OPFS depends onuseWorker.docs/playbooks/integration/*.md— per-framework COOP/COEP setup.