Wasm performance & the perf ceiling Enterprise
This page states honestly what the causl-wasm path costs and what it does — and does not — buy. The short version: crossing the JS↔WASM boundary has a standing wire tax that the pure-TypeScript engine never pays, that tax is bounded (not asymptotic) by a contract in SPEC §18A.7, and the wasm engine earns its keep on the tail-latency and GC-pressure axes — not on the median per-commit cost, where the TS engine is faster. The §18A.7 Criterion-3 perf ceiling is GO / RESOLVED as of 2026-06-19, but that resolution gates "stays responsive within the §14 RAIL budget," not "beats the TS engine," and it does not by itself promote the wasm engine.
1. The marshal tax — why crossing the boundary costs
The TS engine runs natively on the JS event loop: a commit() is a function call, and the Commit envelope it produces is already a JS object. The wasm engine runs in WebAssembly linear memory; every commit must be marshalled across the JS↔WASM boundary. The action payload is serialised into the engine's memory, the engine runs Phases A–H inside WASM, and the resulting Commit envelope is marshalled back out so JS-side subscribers can read it. That round-trip — serialise in, deserialise out — is pure overhead the TS engine never incurs. It is the marshal tax, and SPEC §17.6 G.1 measures it rather than hiding it.
The tax is a standing cost: it is paid before the Rust engine does any useful work, on every crossing, independent of how much the graph computes. Measured against the production serde-json bridge artefact, the full bidirectional commit envelope costs ~15.64 µs/commit; projected over 10,000 commits that is 156.4 ms of pure wire cost — about 78× the entire 2.017 ms median of the TS engine's equality-cutoff × 10000 workload. That ~78×/~85× boundary tax is the documented standing cost named in full at SPEC §17.6 G.1 and §18A.7. It is not hidden in an adoption guide; it sits in front of every wasm-vs-js decision deliberately.
The crucial mitigation is batching. The crossing tax amortises exactly 1/N when N commits cross the boundary in one flush, because the wire is crossed once per flush, not once per commit. The op-rust-batch-boundary probe pins this precisely:
| Batch size N | Per-op boundary cost | Note |
|---|---|---|
| N = 10 | 1564 ns/op | wire crossed once for 10 commits |
| N = 100 | 156 ns/op | amortises exactly 1/N |
| N = 312 | 50.1 ns/op | crosses the §3 ≤50 ns floor at N≥312 |
| N = 1000 | 15.6 ns/op | per-flush marshal, not per-commit tax |
That 1/N amortisation is what makes the isolated per-flush marshal overhead sit comfortably under the §18A.7 ceiling: the quantity the ceiling bounds is the per-flush wire cost, which is small once batched, not the un-amortised per-commit tax.
2. The 250 µs ceiling vs the RAIL budget — two different measurements
This is the distinction that the §18A.7 Criterion-3 resolution rests on, and getting it wrong is the most common way to misread the numbers. The criterion names two separate ceilings that measure two different things:
- The 250 µs/commit ceiling bounds the isolated marshal overhead. This is the JS↔WASM wire cost alone — the crossing tax, with no engine work counted. The relevant figures are the
op-rust-batch-boundaryper-flush numbers above (down to 15.6 ns/op at N=1000). At ≤ 250 µs of marshal overhead per commit, a ≤200-commit interaction stays inside the 50 ms RAIL synchronous budget, and ≤60 commits fit within a single 16 ms frame. - The §14 RAIL budget bounds the end-to-end commit→recompute cascade. This is the full engine-exec latency — the commit plus the entire derive cascade it triggers — not the isolated marshal. RAIL gives a single user-perceptible interaction a 50 ms responsiveness window, and a batch interaction a 200 ms window.
Conflating the two produces nonsense: the marshal tax is microseconds, while the end-to-end cascade is milliseconds, and they are bounded by different gates for different reasons. Criterion 3 passes both — the isolated marshal sits well under 250 µs once amortised, and the end-to-end cascade sits well inside the RAIL window. Crucially, the ceiling gates responsiveness, not parity: it asks "does the wasm path stay inside the human-perception budget," not "does the wasm path beat the TS engine." (It does not, on median — see §4.)
3. The measured numbers (quiet-host, fresh-process)
The confirming benchmark for the §18A.7 Criterion-3 resolution was the taco-mutation smoke tier, run fresh-process per cell on a quiet host (Apple M5, Node v26), where the engine measurement is the full commit→recompute cascade. The end-to-end latencies all land comfortably inside the RAIL responsiveness budget:
| Workload | p95 latency (causl-wasm) | RAIL budget | Verdict |
|---|---|---|---|
| Single-input commit | 2.5 ms | ≤ 50 ms (single) | PASS |
| Batch input commit | 5.5 ms | ≤ 200 ms (batch) | PASS |
| TACO 1000-cell segment | 15.9 ms | ≤ 200 ms (batch) | PASS |
And the isolated marshal/crossing tax — the quantity the 250 µs ceiling actually bounds — is held separately and stays well under it:
| Quantity | Measured | Ceiling |
|---|---|---|
| Isolated JS↔WASM marshal / crossing tax | bounded ≤ 250 µs/commit | 250 µs/commit p95 |
op-rust-batch-boundary per-op (N=10/100/312/1000) | 1564 / 156 / 50.1 / 15.6 ns/op | amortises exactly 1/N |
These are the freshly-corrected, quiet-host figures. The end-to-end cascade numbers (single 2.5 ms p95, batch 5.5 ms p95, TACO-segment 15.9 ms p95) are full engine-exec latencies — commit plus derive cascade — and must not be confused with the isolated marshal cost in the second table. Six of six cells pass the §14 RAIL budget with zero absolute breaches and zero regressions, which is what made Criterion 3 GO / RESOLVED on 2026-06-19.
The synchronous-construction split (SPEC §18A.12) does not move these numbers. Splitting the one unavoidable async (the WebAssembly compile) out of graph construction — see §7 — changes only when work happens, not how much: the per-graph instantiation cost (
new WebAssembly.Instancefrom a cachedModule) and every subsequentcommit()are byte-for-byte the same code path they were before. The benchmark above was re-run after the §18A.12 seam landed and confirmed zero regression on every cell — the engine arithmetic on this page is unaffected by the sync separation.
4. What the wasm path buys — and what it does not
Be honest about the trade. On median per-commit cost, the wasm engine is slower, not faster: roughly 7× to 30× the TS engine on the per-commit median (the smoke run read 6.85× to 28.84× p50). That is consistent with — and does not refute — the standing ~78×/~85× boundary-tax falsification. The marshal tax is real, it is named, and on the median axis the TypeScript engine wins outright. If your hot path is "single mutation, shallow graph, no batching," the wasm loader is paying for a capability you do not use.
Where the wasm path delivers value is on a different axis entirely:
- Tail-latency flattening (p99.9). The Rust substrate walks pointers instead of hash maps and does not produce the major-GC pauses that the JS engine's frozen
Commitallocation andObject.isdispatch generate under load. The re-measured win is roughly 16.6× p99.9 flattening — the worst-case commits get dramatically tighter, even though the median does not. - GC-pressure relief on large trees. The structural floor the JS engine cannot cross — Map allocation, structured-clone of
Commitdeltas, GC pressure on frozen objects — is precisely what crossing into WASM linear memory bypasses. On high-derivation-depth graphs and large-fanout trees, that is where the WASM-side compute amortises the boundary tax.
So the decision rule is: the wasm path is for adopters with p99/p999-sensitive paths and GC-pressure on large graphs, not for adopters chasing a faster median. SPEC §18A.8 says this explicitly — the wasm engine stays "valuable for adopters with workload shapes where the WASM-side compute amortises the boundary tax (high-derivation-depth graphs, p99/p999-sensitive paths)."
5. The §18A.7 gate is met — all five criteria GO (2026-06-21)
The §18A.7 GO/NO-GO gate has five criteria, and promoting the wasm engine to the WASM-path default required all five GO plus a dated governance amendment. On 2026-06-21 that amendment landed (causl-wasm#169): all five criteria are GO, and rust-ssot is the unconditional default. Criterion 3 — the perf floor — was one of the five.
- All five criteria are GO. Criterion 1 (byte-identity, 0-divergence over 1000+ trials), Criterion 2 (full FFI surface — the §18A.3 lift landed, causl-wasm#170), Criterion 3 (perf floor — GO/RESOLVED 2026-06-19, 6/6 RAIL cells PASS), Criterion 4 (Node target shipped +
iasbuilt/xldatagridadopter), and Criterion 5 (TS floor maintained) are all met. The mechanical flip isDEFAULT_WASM_ENGINE_MODE = 'rust-ssot'(verified in source). - The wasm engine is the default; the TS engine is the retained floor. rust-ssot is the value-of-record for the WASM path.
causl-tsstays the §12 conformance reference and the differential oracle in the fork, and is retained insidecausl-clientas the implicit-path capability fallback (§18A.13.1) — not the default. - Criterion 3 gated responsiveness, not parity. The 2026-06-19 resolution superseded the prior 2026-05-13 "beats-TS" perf-floor framing. The ~78× boundary tax still stands as the documented standing cost; perf was explicitly immaterial to the flip (the driver was complexity elimination), and the wasm path stays inside every §14 RAIL responsiveness budget.
6. Re-running the benchmark
The §18A.7 Criterion-3 evidence is reproducible. Build the core package, then run the taco-mutation suite at the smoke tier from the benchmarks package:
pnpm -F @causl/core build
Then, from the benchmarks package, drive run-bench.ts against the taco-mutation suite at the smoke tier (fresh process per cell, threads=1, the engine measurement is the full commit→recompute cascade):
pnpm exec tsx src/cli/run-bench.ts --suite taco-mutation --tier smoke
The per-flush marshal-cost axis — the quantity the 250 µs ceiling actually bounds — is re-confirmed separately by the op-rust-batch-boundary-{10,100,312,1000} probes under the F-marshal.6 bidirectional-envelope methodology. The end-to-end RAIL figures and the isolated marshal figures are different measurements; re-run both if you want to reproduce the full Criterion-3 picture.
7. Synchronous construction — why it costs nothing here
A frequent worry when a wasm engine reaches a synchronous consumer — a React render, a hook body, a grid cell — is that the await the wasm path used to require would either block the call site or force the work onto a slower asynchronous path. SPEC §18A.12 removes that worry without touching any of the perf arithmetic above. It is purely a seam relocation: the one unavoidable asynchronous step (compiling the WebAssembly.Module) is split out of graph construction and moved to a one-time app/init preload, so construction itself becomes fully synchronous. This is shipped — in the differential-oracle fork (causl-ts-wasm-engine) and in causl-client.
The compile is hoisted once, then every graph is built synchronously from the cached module:
// Once, at app/init — the single unavoidable await:
await preloadCauslWasm() // compiles + caches the Module (+ _bg.js
// sidecar + compute-imports snippet), keyed
// by bridge; idempotent (concurrent calls
// share one compile; a transient failure
// drops the cache so the next call retries).
// Anywhere after, at a synchronous call site — zero await:
const graph = createCauslWasmSync() // new WebAssembly.Instance from the
// cached Module; throws
// CauslWasmNotPreloadedError if not
// preloaded. { fallbackToTs: true }
// returns createCauslTs() instead.
Sync peeks — isCauslWasmPreloaded(bridge?) and getPreloadedCauslWasm(bridge?) — let a synchronous consumer check the cache before deciding. The original async constructor is retained and now re-expressed as preloadCauslWasm ∘ createCauslWasmSync — one codepath, no second implementation:
// createCauslWasm(opts?) is now just: preload, then construct synchronously.
const graph = await createCauslWasm() // retained; one async surface left.
Crucially for this page: the split changes only when the compile happens, not the cost of anything that follows. Per-graph instantiation and every commit() run the same code as before, so the marshal tax (§1), the ceilings (§2), and the measured latencies (§3) all carry over unchanged — see the re-run note under §3. On Node, the --target nodejs glue is already synchronous end-to-end, so no preload is needed on the server; the browser bundler target needs the one-time preload because that is where the compile would otherwise land mid-render.
The factory trio
Engine choice is explicit at the call site whenever you want it. Three factories cover the full surface:
| Factory | Engine | Shape |
|---|---|---|
createCauslTs() | pure-TypeScript floor | synchronous |
createCauslWasm() / createCauslWasmSync() | wasm engine | async / synchronous |
createCausl() | in causl-client: wasm (rust-ssot), with a loud TS capability-fallback | synchronous |
causl-client is wasm-default with a TS capability-fallback
causl-client ships wasm as its sole default and public engine (SPEC §18A.13) — but it is wasm-default with a TS capability-fallback, not strictly "wasm-only." createCausl routes to the wasm engine (synchronously, once preloadCauslWasm() has run), and the Rust→WASM engine is the unconditional production engine (rust-ssot is the default). The §18A.3 FFI structural lift has landed (epic causl-wasm#170): every adopter operation — commit / read / subscribe / derived, plus the structural surface (dependencies, dependents, stats, commit‑log, explain, exportModel, readAt / snapshotAt, subscribeCommits) — resolves from the Rust engine; no adopter read consults the TS path. The driver is complexity elimination — one production engine to ship, test, and reason about — and perf is explicitly accepted as immaterial to the decision (the arithmetic on this page is exactly why: the wasm path is already inside every responsiveness budget, and median parity was never the goal). The pure-TypeScript createCauslTs is retained inside causl-client as the §12 conformance reference and, per §18A.13.1 (2026-06-23), wired as the implicit createCausl() path's WasmGC-unavailable capability fallback: on a host where WasmGC cannot instantiate it degrades loudly (one-time console.warn + telemetry, never silent), while explicit createCauslWasm() / engine:'rust-ssot' still fail loud (CAUSL_WASM_ENGINE_UNAVAILABLE). Deleting createCauslTs outright is therefore dropped from near-term scope; the dual-engine floor and the differential byte-identity oracle live in the fork (causl-ts-wasm-engine). (The WasmGC probe itself is still a placeholder, #691 — the auto-degrade is wired in design, not yet a fully-runtime-proven probe.)
This direction is scoped to
causl-clientonly. The forkcausl-ts-wasm-enginedeliberately keeps the dual-engine TypeScript floor — it is the differential oracle that proves byte-identity (SPEC §18A.1.1) and the home of the benchmark suite behind every number on this page. The fork is not going wasm-only.
Build-script enforcement (causl-wasm)
The §18A.12 seam is not left to convention; the causl-wasm build tooling asserts it and fails loud. build_wasm.py / package_wasm.py package the compute-imports snippet next to the .wasm and _bg.js artefacts and verify, at build time:
- the §18A.12 sync seam is present — construction goes through
new WebAssembly.Instancefrom a cached module, not a per-graph compile; - the compute-imports snippet ships alongside the artefacts; and
- node-loadability per bridge: the
wasmgc-classicbridge loads under--target nodejs, whilewasmgc-builtinsemitsrequire("wasm:js-string")— which stock Node cannot resolve — so it is bundler-target only.
Related reading
- Two-engine architecture — the SPEC §18A contract, the shared seven-method surface, and why both engines are byte-identical and conformant for all adopter code.
- Integrating causl-client — consume the wasm artefact through
@causl/core/wasm:loadWasmBackend(), the construction seam, and the structured fallback chain back to the TS floor. - Performance (usage guide) — the §17.5 capability-cost residual band for the TS engine and the canonical bench harness.
- SPEC §18A.7 — the five GO/NO-GO criteria, all GO as of 2026-06-21 (rust-ssot is the unconditional WASM-path default).
- SPEC §17.6 G.1 — the marshaling-cost measurement (the named 78× boundary tax).
- SPEC §14 — the perceptual-perf / RAIL responsiveness budget.
- SPEC §18A.12 — the synchronous-construction seam (
preloadCauslWasm/createCauslWasmSync); shipped, perf-neutral. - SPEC §18A.13 + §18A.13.1 —
causl-clientis wasm-default with a loud TS capability-fallback (shipped; the fork keeps the dual-engine floor).