Performance
This page covers what causl promises about wall-clock speed, how to measure that promise against your own workload, and the optimisations the engine already ships. The short version is that causl pays a structural premium over mobx to ship replay-determinism, and that premium is bounded rather than asymptotic, by a contract in SPEC §17.5.
If you are deciding whether causl is fast enough for your application, the honest answer is: read this page, run the bench harness against a workload that looks like yours, and consult the benchmarks page for the live cross-library sweep. Synthetic microbenchmarks rarely predict app-level behaviour; the harness exists so you do not have to guess.
1. The capability-cost residual band
Causl is a transactional dependency-graph engine. mobx is a reactive observable engine. They look interchangeable from the outside, because both track derivations and both fire subscribers when inputs change, but they ship different capabilities, and those capabilities cost cycles.
Specifically, causl ships four contract surfaces that mobx does not:
commitLogrebuilt on every commit (replay-deterministic IR).changedNodesset construction for Phase G subscriber dispatch.- Monotonic
GraphTimestamping per commit. readAt/snapshotAtretention bookkeeping (historical reads against past commits).
SPEC §17.5 commits to a measurable, bounded cost for those surfaces. On three contract-bearing benchmark cells (equality-cutoff × 10000, equality-cutoff-fanout-10k × 10000, and spreadsheet-100x100 × 10000) the requirement is:
mobx_median × 3.0 ≤ causl_median ≤ mobx_median × 8.0
Read §17.5 as a dated, pre-deprecation record. The 3.0× to 8.0× band above is still the live requirement, and the gate still enforces it. What has been retired is the separate post-Rust forecast band of 1.0× to 4.0×: it was a projection, it never became a requirement, and the measured data does not support it. If you are wiring your own CI against §17.5, anchor on the 8.0× upper bound. That is the boundary that means something.
The band has two boundaries that mean different things:
- Upper bound (8.0×) is the regression gate, and the one to hold. Earlier in the project the
equality-cutoff × 10000cell ran at 1.94 ms against mobx's 0.215 ms, about 9×, breaching the ceiling. A focused perf wave delivered a 24% drop (1.94 ms to 1.47 ms), pulling the residual to roughly 6.4× and giving the 8× ceiling about 25% headroom over the post-wave anchor. A future change that drives the cell back past 8× either reintroduces a per-write per-derived allocation seam or invents a new one; either way, the gate forces the conversation in review. - Lower bound (3.0×) is the capability-erosion gate. The roughly 0.9 ms to 1.1 ms causl pays beyond mobx on
equality-cutoffis, from JavaScript, non-addressable: string-keyed Map probes, full-callObject.is, frozenCommitallocation, and the GC pressure those produce together set a structural floor. A PR that delivers a sub-3× ratio on a contract-bearing cell has most likely retired one of the four contract surfaces, and the floor flips that into a visible CI failure rather than a silent ship. Do not read the floor as a target: no shipped change has crossed it, and the Rust engine did not.
The residual decomposes as 1.84× engine baseline × 3.5× contract premium. The 1.84× factor is the only honest engine-on-engine microbench (op-tx-set-isolated-1k, with the hasDependents fast path that skips the staging and rollback round-trip on isolated inputs). The 3.5× factor is what the four contract surfaces above actually cost.
Methodology callout. Two cells in the wider engine-on-engine harness are excluded from the residual:op-tx-shadow-read-1kreads about 11× because mobx returns the new value directly insiderunInActionwithout a shadow-Map probe, andop-commit-rollback-1kreads about 2.8× because mobx is not transactional (runInActiondoes not roll back on throw, so the comparator simulates a failed transaction with a secondrunInAction). Both are methodology asymmetries rooted in shipped capability rather than in inner-loop cost, and folding them into the residual would produce a number neither side could defend.
2. Running the bench harness
The bench workspace ships every measurement that backs SPEC §17.5. You can run the same harness against your local checkout in two flavours: a quick run and a reportable run.
Quick run
pnpm bench
This drives the default profile, sweeping the canonical cells (diamond, scrolling-viewport, dynamic-deps, async-race, batch-commit, equality-cutoff, large-fanout) at the production scales and printing wall-clock medians per library per cell. Use it in development to spot order-of-magnitude regressions in the inner loop you are working on.
Full report
pnpm bench:report
This writes the results and history JSON and emits a Markdown table with median Δ%, p95 Δ%, CoV, and per-cell win/loss attribution. The same JSON shape feeds the regression gate (pnpm bench:gate) and the hypothesis-catalogue check (pnpm bench:check-hypotheses). Run this when you want a number to put in a PR body.
Each run aggregates a per-cell measurement across multiple trials of n=50 samples. Aggregate median, p95 and stddev are taken as the trial-medians, and CoV = stddev / median. A single n=50 stripe on the contract cells reads CoV in the 0.10 to 0.25 range, which is too wide to detect a 10% effect; aggregating five trials drops the trial-median's standard error far below the gate threshold. The published per-cell CoV envelopes, visible in every report and committed alongside the regression baseline, are the input you need to decide whether a delta you observe is signal or noise.
Detailed explanation. The harness reports both wall-clock and stddev/CoV because the contract is about the median, not the best case. If you find yourself wanting to quote a "fast run" number, that is the cue that your environment is noisier than CI's and you should run more trials rather than pick the lowest sample. The CI gate intentionally fires on the median.
Confidence is not uniform, and I am not going to bury that. On the most recent full sweep, 45% of the ok cells (171 of 380) carry a low confidence grade, driven by dispersion, drift, truncated warm-up, and machine load. Low-confidence cells are excluded from the ranking, which is the right call, but it means a single cell you pick out of the table may not be a number you can lean on. Check the confidence grade before you quote a cell.
Targeted cells
For PR-sized investigations the focused driver runs only the three SPEC §17.5 cells in seconds rather than the 30+ minutes a full sweep takes:
pnpm bench --scenarios equality-cutoff,equality-cutoff-fanout-10k,spreadsheet-100x100 --scale 10000
The smaller-cell entry points share the same harness, so the numbers compose with the full report.
2.5 What the §17.5 band measures
The §17.5 band is a TypeScript-engine contract: it measures the TypeScript engine (the one that lives in causl-core-ts and still defaults to js-ssot) against mobx on the three contract cells, at 3.0× to 8.0× mobx_median. That engine is the differential oracle and the benchmark host. It is not what an adopter installs: @causl/causl-wasm-ts runs the Rust engine unconditionally.
3. The equality-cutoff optimisation
Causl's most consequential commit-time optimisation is the equality cutoff: if a staged write to an input would set the same value already in the cell (Object.is(prev, next) returns true), the engine drops the write before Phase D and never recomputes downstream derived nodes.
This sounds obvious; the consequence is not. In a derived graph where a single input writes its dependent column on every commit, the equality cutoff turns a topological-recompute-of-everything into a topological-recompute-of-nothing. The equality-cutoff × 10000 cell is named after exactly this scenario: 10,000 derived nodes fanning out from one input that is written to with a value that compares equal to its current value.
import { createCausl } from '@causl/causl-wasm-ts';
const graph = createCausl();
const source = graph.input('source', { count: 0 });
const doubled = graph.derived('doubled', get => get(source).count * 2);
graph.commit('rewrite-source', tx => {
// Same value: equality cutoff drops the write, doubled is NOT recomputed.
tx.set(source, { count: 0 });
});
Two things are worth knowing about this:
- The comparison is
Object.is, not deep equality.tx.set(source, { count: 0 })in the snippet above passes a new object reference each call, soObject.isreturns false and the write commits. If you want the cutoff to fire for value-equal objects, you must keep the reference stable yourself, or compute the value into a variable and pass that variable in. - The cutoff runs in Phase C (input-layer fast path), before Phase D (Kahn topo recompute). That is why
equality-cutoff × 10000is so much cheaper than a write that does propagate: when the cutoff fires, the engine never enters the topological walker. See SPEC §5.1 for the phase diagram.
If your application looks like the equality-cutoff cell, with many writes that are no-ops at the value level, causl's wall-clock will track mobx's almost exactly, modulo the 3.5× contract premium. If your application looks like scrolling-viewport, where every commit mutates real data, the equality cutoff does not help and you should profile.
4. Scrolling-viewport: a worked perf recovery
The scrolling-viewport × 10000 cell is a 1,000-cell virtualised grid scrolled at 60 Hz: every frame mounts roughly twenty new rows, unmounts twenty old rows, and writes per-row inputs. It is the cell most representative of real DX, that is, mount and unmount churn under user input.
At an early-2026 snapshot, this cell was running at a catastrophic 654× mobx on the engine-on-engine harness. The root cause turned out to be a single hot helper, anyInputSubscriberIn, doing an O(n) walk over the subscriber list on every Phase G dispatch, and Phase G fires once per row mount. At a 1,000-row viewport, the walk cost was multiplied by the row count.
The recovery collapsed anyInputSubscriberIn to O(1) via the subscriptionsByNode index that was already maintained for the dispatch fast path. The hypothesis-catalogue row went from INVALIDATED: anyInputSubscriberIn at 26.92% (≥5%) to PASS: no match exceeds 5% threshold for "anyInputSubscriberIn"; the cell's median dropped by 97%, and the 654× reading collapsed into single-digit territory consistent with the §17.5 band. There is no longer a residual on this cell that the wave program needs to defend.
Why this is worth remembering. The 654× figure was not a competitor cherry-pick; it was the engine's own reported wall-clock against its own canonical workload. The recovery happened because the hypothesis catalogue named the suspect symbol, the bench harness measured the suspect symbol's contribution, and the fix was small enough to ship in one PR. If you observe a regression of similar shape in your own profile, the same loop applies:pnpm bench:check-hypothesesnames the candidate symbols,pnpm bench:profile:cpuattributes the wall-clock, and the fix should land in one PR with a perf delta in the body.
5. Which engine these numbers describe
Everything above describes the TypeScript engine, which is the engine the §17.5 band contracts and the engine the bench harness hosts. It honours every contract surface and meets the residual band, and it is the reference the differential oracle runs 5,000 trials per PR against.
It is not what an adopter runs. In @causl/causl-wasm-ts the Rust engine is unconditional: there is no opt-in, no fallback, no capability probe deciding between two engines, and createCauslTs is deleted from source. If you install the client, you get Rust.
There is no crossover. Earlier revisions of this page said the wasm engine overtakes the TypeScript engine above some measurable graph size. The sweep does not show that. Across 75 comparable cells the Rust engine wins 3 against the TypeScript engine, and all three are cells where the TypeScript engine goes quadratic rather than cells where wasm is fast. Against the best of the other four libraries it wins 0 of 75. The median ratio against the TypeScript engine is 6.90×. Do not plan a migration on the expectation that a large enough graph flips the result.
6. Where the residual cannot be addressed
It is worth restating the §17.5 lower-bound argument in adopter terms. If your profile of a contract-bearing cell shows the bulk of the wall-clock in Map.prototype.get, Object.is, structured-clone of Commit deltas, or major-GC pauses, those are the floor: no JavaScript-level optimisation crosses below them while preserving the contract surfaces.
What that argument does not license is the conclusion that a substrate swap is the lever. On the current sweep the Rust engine is slower on the great majority of cells, and its peak RSS exceeds the TypeScript engine's in every comparable cell measured. Read the Enterprise wasm-performance guide before you treat the engine as a performance answer.
Conversely, if your profile shows the bulk of the wall-clock in user-provided derived bodies, the engine is already as fast as the contract allows and the answer is to look at the derive function itself. pnpm bench:profile:cpu causl <scenario> <scale> attributes user code separately from engine code in the flame graph, so the question "is this me or is this causl?" is mechanically answerable.
Related reading
- Benchmarks landing page: the live cross-library sweep.
- SPEC §17.5: the capability-cost residual amendment.
- SPEC §17.6: the host-tier substrate-compatibility commitment.
- Wasm performance & the perf ceiling Enterprise: where the Rust engine stands, the bottlenecks behind it, and the
read()-identity hazard. - Installation: the registry configuration, and the
@causl/corehazard. - Profiling guide: flame graphs, deopt traces, GC pause distributions.