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 NPer-op boundary costNote
N = 101564 ns/opwire crossed once for 10 commits
N = 100156 ns/opamortises exactly 1/N
N = 31250.1 ns/opcrosses the §3 ≤50 ns floor at N≥312
N = 100015.6 ns/opper-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:

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:

Workloadp95 latency (causl-wasm)RAIL budgetVerdict
Single-input commit2.5 ms≤ 50 ms (single)PASS
Batch input commit5.5 ms≤ 200 ms (batch)PASS
TACO 1000-cell segment15.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:

QuantityMeasuredCeiling
Isolated JS↔WASM marshal / crossing taxbounded ≤ 250 µs/commit250 µs/commit p95
op-rust-batch-boundary per-op (N=10/100/312/1000)1564 / 156 / 50.1 / 15.6 ns/opamortises 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.Instance from a cached Module) and every subsequent commit() 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:

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.

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:

FactoryEngineShape
createCauslTs()pure-TypeScript floorsynchronous
createCauslWasm() / createCauslWasmSync()wasm engineasync / synchronous
createCausl()in causl-client: wasm (rust-ssot), with a loud TS capability-fallbacksynchronous

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-client only. The fork causl-ts-wasm-engine deliberately 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:

Related reading