Wasm performance Enterprise

This page reports what the wasm path costs, measured rather than modelled. For a long time it led with the JS↔WASM marshal tax as the engine's ceiling, and I retract that in full in §3: a raw boundary crossing costs 1.6 ns on the benchmark host, which works out to 0.13% of the cell it was supposed to dominate. The boundary was never the constraint.

Three costs are. In order of how much they hurt: a residual per-commit clone proportional to the number of live cells (not touched cells), a per-recompute constant of roughly 2.5 µs, and a per-commit constant of roughly 3.1 to 4.4 µs. None of the three is the crossing itself. Two of them are known to be constants I can attack; the first is a bug with an open issue and a located line of source.

1. The architecture, as it actually ships

Two packages matter to an adopter, and a third exists only to keep them honest.

RepoPackageRole
causl-core-rs@causl/core-rsThe Rust engine, compiled to WebAssembly. This is the engine.
causl-wasm-ts@causl/causl-wasm-tsThe thin TypeScript client you import. Boot, handles, callbacks.
causl-core-ts@causl/core-tsDifferential oracle, dual-engine floor and benchmark host. Never published.

Three older names appear in prose that predates the 2026-07-27 rename and all three are retired: causl-client is now causl-wasm-ts, causl-wasm as a repository is now causl-core-rs, and causl-ts-wasm-engine is now causl-core-ts. Each survives only as a redirect. The package @causl/core deserves its own warning: it has zero versions on the Gitea registry, but the name resolves on public npmjs to a different package, so a stale pin installs successfully and gets the wrong thing rather than failing loudly. Do not import @causl/core.

There is no TypeScript fallback

§18A.13.1, the implicit capability fallback that let createCausl() degrade to the TypeScript engine on a host that could not instantiate the wasm one, was withdrawn at 0.5.0. The SPEC heading now says so verbatim and adds that the section should be read as a dated record, not as a live obligation. I withdrew it for two reasons. The fallback engine and the primary disagreed (causl-wasm-ts#272, in-place mutation of a committed value, on a surface §18A.1.1 requires to be identical), and the implicit path was the accident path rather than a chosen one: 0.4.0 warned on it, 0.5.0 throws.

createCausl() is now construct-or-throw, two statements at packages/core/src/graph.ts:1500. createCauslTs is deleted from source: git grep createCauslTs over packages/*/src/** returns zero hits, and it survives only in tests that assert its absence and in dated records. Any documentation claiming it is "retained internally" is out of date, including earlier revisions of this page.

// Once, at app init. This is the only async seam in the whole client. await preloadCauslWasm(); // Anywhere after that, synchronously. Construct or throw. const graph = createCausl(); // Failure is a code. Branch on it, never on instanceof. try { createCausl(); } catch (err) { switch (err.code) { case 'CAUSL_WASM_ENGINE_UNAVAILABLE': // the wasm subpath was never imported, or the host cannot instantiate break; case 'CAUSL_WASM_NOT_PRELOADED': // imported, but preloadCauslWasm() never resolved break; default: throw err; } }

createCauslWasmSync() is the explicit synchronous constructor and createCauslWasm() is retained as preloadCauslWasm ∘ createCauslWasmSync, one codepath rather than a second implementation.

Host floor, and what actually governs it

HostMinimum
Safari18.2
Chromium119
Firefox120
Node22

The governing requirement is typed function references, established by feature-bisecting the shipped artefact in causl-wasm-ts#426. It is not WasmGC heap types: the artefact declares zero WasmGC struct, array or rec-group types, so a host that lacks GC heap types but has typed function references runs it fine. The floor lives in source at packages/core/src/wasm-registry.ts:94.

Treat the floor as a release-note claim, not as our measurement. Nobody has run the artefact on a real boundary host. The retired floor (Safari 18, a macOS-15 pairing, and a Node ceiling of 20) was wrong on three counts, which is exactly why I am labelling the confidence on the replacement.

Two more corrections that touch this page directly. There are two exported functions named detectBridge(), and they behave oppositely, so an unqualified claim about "the probe" is wrong whichever way it points. The one on the /wasm subpath is a real probe: #426 wired it to a 56-byte floor module and it throws WasmEngineUnavailableError on a host that fails. The one re-exported from the main barrel (packages/core/src/bridge.ts) is still a placeholder whose marshalling primitives all throw "pending #692", and it advertises gc: false on hosts where the engine instantiates perfectly well. So if you call detectBridge() from the default import you get the stub. An earlier revision of this page said flatly that the probe is real, which was true only of the subpath, and I got that wrong. And only one bridge ships, gc-classic. gc-builtins was deleted in causl-core-rs#355 and no longer exists as a build target. The ids are gc-classic and gc-builtins, never wasmgc-classic or wasmgc-builtins; earlier revisions of this page used the wrong spelling.

Multi-instance isolation is solved by engine_id multiplexing over a single WebAssembly.Instance per process, verified isolated at N=16. One behavioural caveat that matters to the cost analysis below: read() reference identity is not guaranteed across commits (§15.1), and the engine is not uniformly fresh either. A ValueHandleCache retains the original adopter reference, so identity is stable on a cache hit and fresh only on decode paths. The break is intermittent, which is harder to catch than a uniform one. That same cache is a measurable slice of §2.2.

2. Where the time actually goes

2.1 A residual per-commit clone of every live cell

This is the largest single cost and the only one of the three that scales with graph size rather than with work done. It is tracked as causl-core-rs#392, which is open and untouched by the engine.

The mux-ballast scenario exists to isolate exactly this, and it is the strongest result in the dataset because it removes every competing explanation by construction. Its stepOps are byte-identical at all three scales: 100 writes, 100 commits, 20,100 edge reads, 10,200 recomputes. The only thing that changes between rows is how many derived nodes exist and are never touched.

Derived nodescausl-wasm-tscausl-tsmobxjotairedux-rtk
30127.9145 ms3.53122.250010.50054.0044
120138.5052 ms3.54812.291310.46894.2788
10201141.9967 ms3.60982.452610.68444.4028
growth5.09×1.02×1.09×1.02×1.10×

Four libraries stay flat between 1.02× and 1.10×, which is what identical work on a larger idle graph should cost. One pays 5.09× for nodes nothing ever reads or writes. Fitting T = α + β·Dn over the three rows gives β = 115 ns per untouched derived node per commit, and that fit predicts the middle row to within 0.6%.

The mechanism is located in engine source, not inferred:

The sharpest corroboration is equality-cutoff-noop, because it strips away everything the clone could be confused with. That cell performs zero recomputes and zero edge reads and issues one no-op commit. No recompute means no compute callback and no value marshalling. Its cost still quadruples with graph size, 0.0249 ms to 0.0883 ms (high confidence at the 10k scale), while causl-ts stays flat at 0.0020 to 0.0022. Nothing scaled in that cell except the number of cells to clone.

2.2 About 2.5 µs per recompute, and it is the payload, not the crossing

Four independent estimates converge on the same constant, which is why I trust it more than any single cell:

Estimatecausl-wasm-tscausl-ts
mux-ballast at 1000, minus the fitted clone term2.508 µs0.341 µs
op-derived-recompute-1k (high confidence)2.481 µs0.618 µs
equality-cutoff at 100002.472 µs0.618 µs
linear-chain at 100002.781 µs0.403 µs

That is 6.2× to 7.4× causl-ts per recompute. On mux-ballast at 1000 the clone term of §2.1 accounts for 8.3% of the cell and this constant accounts for 91.7%, so on any graph that is not enormous, this is the cost that dominates.

The important part is where the 2.5 µs sits. It is the callback's payload, not its crossing. Rust already crosses once per affected derived and no more (compute_bridge.rs:952-1063, the minimum-crossings floor), with no per-derived allocation on the Rust side. The JS side is where the allocation happens. dist/wasm.js:3188-3246 (#onCompute) allocates a Map, an Array, a Set and a closure per recompute, plus a depsBufView.slice() on cache miss. Every ValueHandleCache touch is four Map operations (has, get, delete, set) and there are at least two touches per recompute.

So the fix here is a shape change on the JS side, reusing scratch structures across recomputes rather than allocating fresh ones, and not a reduction in crossings. There are already as few crossings as the design allows.

2.3 About 3.1 to 4.4 µs per commit, before any derived work

A commit that touches no derived node at all still costs microseconds:

Cellcausl-wasm-tscausl-ts
op-tx-set-equal-1k3.145 µs0.169 µs
op-commit-noderived-1k4.127 µs0.303 µs
op-tx-set-isolated-1k4.305 µs0.323 µs
op-no-subscriber-firehose-1k4.415 µs0.342 µs

That is 13× to 19× causl-ts on the same cells. Creating a derived node is worse in isolation: 11.67 µs against causl-ts's 0.573 µs, a factor of 20.4. If your workload builds and tears down graphs frequently rather than committing into a long-lived one, this is the number that will bite you first.

3. What I published here before, and what I retract

This page has been serving wrong performance claims, so I am correcting them by name rather than deleting them quietly. Anyone who cited a figure from an earlier revision can see exactly which one moved and why.

Claim previously published on this pageStatus
The JS↔WASM marshal tax is the engine's ceiling: a standing wire cost paid before the engine does any useful work. Refuted. A raw boundary crossing is 1.6 ns on this host. One linear-chain iteration at scale 1000 measures 2015 crossings, which is 3.2 µs against a 2.4466 ms cell: 0.13%.
"~15.64 µs/commit of pure wire cost", and the ~78×/~85× boundary tax derived from it, measured against the production serde-json bridge artefact. Withdrawn twice over. 15.64 µs is 3.8× larger than the entire measured commit (4.127 µs, op-commit-noderived-1k), so it cannot be a component of it. And the serde bridge is retired: only a dead legacy_commit.rs surface still calls serde_wasm_bindgen, so the artefact it was measured against no longer ships.
The op-rust-batch-boundary probe shows the tax amortising exactly 1/N: 1564 / 156 / 50.1 / 15.6 ns/op at N = 10 / 100 / 312 / 1000. The probe no longer exists. Its successors op-wasm-boundary-1k and op-rust-bridge-floor-1k report inapplicable on every runner: they target a Phase-0 stub seam whose package is gitignored and absent. No amortisation curve is currently measured, so I am publishing none.
Value marshalling is a first-order cost on reads. Third order. op-read-cold-1k is 77.4 ns per read against causl-ts's 18.6 ns, roughly 4×. That is 3.1% of a recompute and 1.9% of a commit. Real, and not the story. Both cells are low confidence, so I quote it as roughly 4× rather than to three digits.
Tail-latency flattening of roughly 16.6× at p99.9, and GC-pressure relief on large trees. Unsupported, and the memory direction is the opposite. Nothing in the current dataset measures a p99.9 win. Peak RSS exceeds causl-ts in 75 of 75 comparable cells, median 1.70×, worst 893.0 MB against 161.7 MB on readFraction at 10000. Wasm linear-memory growth reaches 194.75 MB in that cell and 137.31 MB on linear-chain at 10000.
createCauslTs is retained inside the client as the §18A.13.1 capability fallback, and the WasmGC probe is still a placeholder (#691). Both false as of 0.5.0. §18A.13.1 is withdrawn, createCauslTs is deleted from source, and detectBridge() is a real probe. See §1.
The wasmgc-classic and wasmgc-builtins bridges, with node-loadability differing between them. Wrong ids, and one of the two is gone. Only gc-classic ships; gc-builtins was deleted in causl-core-rs#355.

The structural error worth naming on its own is the ceiling framing. Earlier revisions organised the whole page around a 250 µs/commit bound on isolated marshal overhead and treated clearing it as evidence of health. Isolated marshal overhead turns out to be 0.13% of the cell. A bound on a quantity that small was always going to be cleared, and clearing it never implied the engine was fast. That is why this revision leads with the costs themselves instead.

One more thing that was never shipped. The JSON value-marshalling change (BTreeMap to IndexMap) is a recommendation, not a landed change. The follow-up PR has not merged and the two cells built to measure it report inapplicable. Do not plan around it.

4. Where the engine stands today

Against causl-ts across 75 comparable cells: min 0.023×, p25 4.15×, median 6.90×, p75 13.33×, max 51.95×. The median is the honest headline. On a typical cell the wasm path takes about seven times as long as the TypeScript engine it replaced.

It wins 3 of 75 cells, and I want to be precise about what those wins are, because all three are places where causl-ts is quadratic rather than places where the wasm engine is fast:

CellRatiocausl-wasm-tscausl-ts
equality-cutoff-fanout-10k at 100000.023×2.2077 ms94.5076 ms
batch-commit at 100000.149×14.7160 ms98.5225 ms
op-commit-rollback-1k0.637×5.4110 µs8.4983 µs

causl-ts fits N^2.09 and N^1.88 on the first two. The wasm engine wins there by not being quadratic, which is a real property worth having, but it is a different claim from being fast.

Against the best of the other four libraries in each group, it wins 0 of 75 cells. In the harness ranking it appears in 8 comparable groups and places last in every one.

What it is currently good for

What it is not currently good for

5. What genuinely improved

causl-core-rs#331 is fixed, and it was the big one. On linear-chain from 1000 to 10000, the scaling exponent went from N^1.932 on the quarantined 0.3.6 build to N^1.041 on the build the current run measures. The quadratic coefficient fell 14.6×, with 6.9% surviving.

That is the difference between an engine that cannot be used on a large graph at all and one that is linear but carries constants I can still attack. Everything in §2 is the linear remainder of that fix, which is why I am reporting constants rather than exponents this time.

Related open work worth tracking: causl-wasm-ts#500 is a red-by-design test pinning commit cost against graph size. Its bisection is informative on its own: the command buffer stays at 40 bytes and the diff buffer at 120 bytes at both the 1000 and 10000 scales, the JS half stays effectively flat at 4.6 µs to 5.1 µs, and the time spent inside the wasm call goes from 25.1 µs to 147.4 µs. Buffer sizes did not move, so it is not a marshalling volume problem.

6. How these numbers were measured, and what they cannot tell you

Source: causl-bench/combined.json, combined at 2026-08-11T04:13:45Z, schema version 2, commit e73620e.

ParameterValue
HostApple M5, darwin-arm64, Node v26.5.1
Repetitions20, forced GC between reps
Unitfresh-world-per-step
Isolationsubprocess-per-cell
Clockprocess.hrtime.bigint
Cells420 total: 380 ok, 31 inapplicable, 9 skipped
Homogeneityuniform: true

The engine under test is attested rather than asserted. The runner measures @causl/causl-wasm-ts@0.3.7 against the gc-classic binary causl_engine_bridge_bg.wasm, sha256 ab4b62c1…, 714,194 bytes. That hash is what distinguishes it from the quarantined 0.3.6 build (e6883840…), whose numbers were withdrawn. The engine floor is cleared and the runner is now ranked rather than held out.

These numbers measure 0.3.7, and 0.5.0 is what ships

I want this stated before any figure on this page is quoted elsewhere. The harness pins its engine floor at @causl/causl-wasm-ts@0.3.7 because that is the release which cleared causl-core-rs#331 (causl-wasm-ts#294), and it measures exactly that version. The Gitea registry currently serves three versions of the client, 0.3.6, 0.3.7 and 0.5.0, and 0.5.0 is latest. So every measurement on this page is two minor releases behind the client an adopter installs today.

What I can say about the gap is narrow. 0.5.0 is the release that withdrew the §18A.13.1 capability fallback and turned createCausl() into construct-or-throw, which is a change to the engine-selection surface rather than to the commit or recompute path. What I cannot say is that the hot path is unchanged between the two, because nothing in this run measures 0.5.0. Read every figure here as describing 0.3.7, and treat a re-measurement against 0.5.0 as outstanding work rather than as a formality.

The confidence caveat, stated rather than buried

45% of ok cells are low confidence: 171 of 380. The drivers are dispersion (100 cells), drift (79), truncated warmup (13) and machine load (8). Low-confidence cells are excluded from ranking, but they are not excluded from the per-cell figures quoted on this page, so where a number carries a confidence label I have given it, and where two cells are both low confidence I have rounded the ratio rather than quoting three digits. Treat single-cell numbers as indicative and the converging four-estimate figure in §2.2 as the solid one.

The gap I have not closed

There is no profile of the 0.3.7 binary this run measures. All 24 captures in causl-bench/profiles/ are of the quarantined 0.3.6 build. The consequence is specific and it matters: the 2.5 µs per recompute is not yet split between JS-side #onCompute machinery and Rust-side per-node work. That split decides whether the next fix is a client PR or an engine PR, so until someone captures a profile of the shipping binary, the attribution in §2.2 rests on source reading plus the #500 bisection, not on a profile.

Why the historical wasm series disappeared

An earlier harness published a causl-wasm series whose engine label was asserted rather than observed: the column labelled "wasm" was in fact the TypeScript engine. Those samples are being deleted from the public feed rather than relabelled, because relabelling them would attach a Rust-engine claim to TypeScript-engine measurements. The attested causl-wasm-ts series takes over the causl-wasm name, so from now on one series carries that label and it is backed only by rust-ssot measurements. Losing the historical records is accepted and intended.

7. What I would fix next, and what is tracking it

Everything above diagnoses. This section says what follows from it, so a reader can tell which findings are being worked and which are only recorded. Each row names an issue in a private repository, so I give the identifier as text rather than a link.

OrderWorkTracked as
0 Capture a profile of the shipping binary. This comes first because it is not itself an optimisation, it is what tells the next two apart. Every capture in causl-bench/profiles/ is of the quarantined 0.3.6 build, so nothing yet splits the per-recompute cost between the JS #onCompute path and Rust-side work. That split decides which repository the fix belongs in. causl-bench#178
1 Stop cloning both cell tables on every derived-carrying commit. The largest single win available, and the only one already scoped to a located line of source. A commit currently costs O(graph) where the contract says O(dirty set). A client-side draft PR carrying the probe and a pinned regression is blocked on the engine half. causl-core-rs#392, with causl-wasm-ts#500 measuring it and #504 pinning it
2 Cut the per-recompute constant. At 91.7% of the mux@1000 cell this is the biggest number on the page, but it is second in order because it cannot be routed without step 0. The candidates are the per-call Map, Array, Set and closure allocations, and the four Map operations per ValueHandleCache touch. causl-wasm-ts#533
3 Cut the per-commit constant. The floor underneath the other two, and the cost that batching cannot amortise because it is per-commit by construction. It matters most for workloads that commit often and derive little. causl-wasm-ts#534
4 Re-measure against 0.5.0. The figures on this page describe 0.3.7. Closing this is a re-run, not an optimisation, but until it happens the site cannot claim its numbers describe the shipped client. rides with causl-bench#178; the recurring condition is recorded on causl-wasm-ts#432
5 Swap JsonValue's BTreeMap for an IndexMap. Benchmarked at 22% to 58% faster keyed reads at object sizes 20 to 100. I rank it last deliberately: value marshalling is third-order at 77 ns per read, which is about 3% of a recompute. It is here because it is measured, self-contained, and was sitting in a document with nothing tracking it. causl-core-rs#393

On the ordering. Steps 2 and 3 are both larger than step 1 in raw microseconds, and I still put step 1 first. Step 1 is the only one where I can point at the code that causes it, so it is the only one I can promise a fix for. Steps 2 and 3 are constants I can describe but not yet attribute, and shipping a patch against a cost I have not located is how an optimisation turns into a rewrite.

Related reading