Custom multi-threaded build
Declare a threads variant in libcascade.config.ts, load it with createInstance, and size the pthread pool.
The npm package already ships a pre-built multi-threaded variant — see Package — Multi-threaded build for the import recipe, COOP/COEP headers, and OCCT activation calls. Build your own only when you need:
- A trimmed symbol list.
- Different link settings (alternative
STACK_SIZE,INITIAL_MEMORY, a cappedPTHREAD_POOL_SIZE). - A browser-only variant with growable array buffers (see below).
Declare the variant
A threaded build is not a separate config. It is a variant of the same one:
import { defineBuild } from '@libcascade/toolchain';
export default defineBuild({
name: 'occt',
bindings: ['BRepMesh_IncrementalMesh', 'TopoDS_Shape', 'gp_Pnt'],
settings: {
MODULARIZE: true,
EXPORT_ES6: true,
ALLOW_MEMORY_GROWTH: true,
MAXIMUM_MEMORY: '4GB',
STACK_SIZE: 8_388_608,
WASM_BIGINT: true,
ENVIRONMENT: ['web', 'worker', 'node'],
EXPORTED_RUNTIME_METHODS: [
'FS',
'wasmMemory',
'getExceptionMessage',
'incrementExceptionRefcount',
'decrementExceptionRefcount',
],
ERROR_ON_UNDEFINED_SYMBOLS: false,
EVAL_CTORS: 2,
},
compilerFlags: { optimize: 'O3', simd: true, exceptions: 'wasm', noEntry: true },
variants: [
{ name: 'single' },
{
name: 'multi',
compilerFlags: { threads: true },
settings: {
// Constructor evaluation order is non-deterministic under pthread
// workers, so the base EVAL_CTORS is removed for this variant.
EVAL_CTORS: null,
SHARED_MEMORY: true,
PTHREAD_POOL_SIZE: 'navigator.hardwareConcurrency',
},
},
],
});compilerFlags: { threads: true } renders -pthread, and it is the line that
matters twice over. From it the toolchain infers the variant's threads
capability, which selects the multi-threaded container image at build time and
becomes the capability the generated createInstance probes before handing you
an instance at load time.
You can still write requires: ['threads'] explicitly, but you do not need to:
a build against shared memory cannot instantiate without SharedArrayBuffer, so
the capability follows from the flags. An explicit requires is added to what
is inferred — declaring requires: [] does not opt a threaded build out of its
own probe, by design.
npx libcascade build --variant multi
npx libcascade assembleLoad it
import { createInstance } from 'my-occt-package/init';
const oc = await createInstance(); // best variant this host supports
const mt = await createInstance({ variant: 'multi' }); // explicit
const capped = await createInstance({ variant: 'multi', threadCount: 4 });There is no mainScriptUrlOrBlob to pass, no file: URL to convert for Node
workers, and no thread-pool call to remember: createInstance does all three.
Asking for 'multi' on a host without cross-origin isolation throws with the
remediation in the message rather than failing deep inside module
instantiation.
Browsers gate SharedArrayBuffer behind cross-origin isolation, so every page
loading the threaded binary needs:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpNode exposes SharedArrayBuffer unconditionally. Under Vite, add
worker: { format: 'es' } — Emscripten's workers are ES modules with top-level
await and the default iife format cannot emit them. Both are covered in
Variants and assemble.
Sizing the pool
PTHREAD_POOL_SIZE: 'navigator.hardwareConcurrency' is evaluated by the
generated glue at module-instantiation time, so one binary adapts to whatever
hardware loads it: an M2 Pro browser gets 12 workers, a mobile device 4–6, a
Node server whatever os.cpus().length reports.
Each worker costs STACK_SIZE (8 MB above) at module init, so a 12-core box
reserves ~96 MB of stack on top of INITIAL_MEMORY. To cap it, either wrap the
expression in the setting or pass threadCount at load:
const oc = await createInstance({ variant: 'multi', threadCount: 8 });threadCount sizes OCCT's default pool and raises its launch cap to match. It
does not shrink the pre-spawned worker pool baked into the binary — that is a
build-time number.
Why not a small fixed pool size?
A round number like
PTHREAD_POOL_SIZE: 4caps concurrency on larger machines and oversubscribes smaller ones.'navigator.hardwareConcurrency'follows the host without a separate build. Add a cap only after benchmarking your workload and target devices; the published benchmark measures 12 workers and does not claim a universal optimum.
Browser-only MT build
If you can guarantee recent browsers (Chrome / Edge ≥ 144, Firefox ≥ 154, Safari ≥ 26.2 — August 2026 baseline) and do not need the binary under Node.js, Bun, or Deno, add a third variant so typed-array views track memory growth:
{
name: 'multi-browser',
compilerFlags: { threads: true },
settings: {
EVAL_CTORS: null,
SHARED_MEMORY: true,
PTHREAD_POOL_SIZE: 'navigator.hardwareConcurrency',
GROWABLE_ARRAYBUFFERS: true,
ENVIRONMENT: ['web', 'worker'],
},
}Emscripten then wraps the pthread SharedArrayBuffer in a
resizable ArrayBuffer view
via WebAssembly.Memory.prototype.toResizableBuffer(). Typed arrays created
from oc.wasmMemory.buffer stay valid across memory growth, and the
-Wpthreads-mem-growth advisory disappears.
Runtime support matrix —
toResizableBuffer()(August 2026)Supported — ship browser-variant artefacts to:
- Chrome / Edge ≥ 144
- Firefox ≥ 154
- Safari ≥ 26.2 (desktop + iOS)
NOT supported — fall back to the plain threads variant for:
- Node.js (all current LTS) — throws
TypeError: wasmMemory.toResizableBuffer is not a functionon module init.- Bun and Deno (all current versions).
- Samsung Internet and other Chromium derivatives that lag mainline.
- Firefox ≤ 153 — WebAssembly worker transfer can hang even when the API exists; the engine fix ships in Firefox 154.
Because Node.js is the dominant test-harness runtime, the shipped multi-threaded build keeps
GROWABLE_ARRAYBUFFERSoff so it stays loadable undervitest,jest, and other Node-based runners. Opt in only for production payloads served directly to browser clients.
The selector picks the most capable variant whose requirements probe true and
breaks ties by configuration order, so a browser-only variant needs an explicit
createInstance({ variant: 'multi-browser' }) — or the pre-import override
symbol — rather than automatic selection.
Relaxed SIMD — advanced, non-default
Safari 26.x does not support relaxed SIMD. If profiling justifies it for Chromium, Firefox, or Node, add a sibling variant with
rawFlags: ['-mrelaxed-simd'], retain the baseline binary, and select it yourself whenever relaxed-SIMD validation fails.
After building
Activation calls, parallel-aware OCCT APIs, and benchmarks are the same as for the shipped binary — see Package — Multi-threaded build.
Related
- Variants and assemble — selection, overrides, and the generated entries.
- Emscripten settings and flags — the settings baseline.
- Trim symbols — shrink the symbol set before building.