Performance
This page covers what causl promises about wall-clock speed, how to measure that promise against your own workload, the optimisations the engine already ships, and when to opt in to the WebAssembly backend. The short version is that causl pays a structural premium over mobx to ship replay-determinism, and that premium is bounded — not 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 canonical seven-cell 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 — both track derivations, 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 engine guarantees:
mobx_median × 3.0 ≤ causl_median ≤ mobx_median × 8.0
The band has two boundaries that mean different things:
- Upper bound (8.0×) is the regression gate. Earlier in the project the
equality-cutoff × 10000cell ran at 1.94 ms vs mobx's 0.215 ms — about 9×, breaching the ceiling. A focused perf wave delivered a 24% drop (1.94 ms → 1.47 ms), pulling the residual to ~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 ~0.9–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 future PR that delivers a sub-3× ratio on a contract-bearing cell has either shipped a real architectural breakthrough (the leading example — promotion of the native wasm engine Enterprise to the default backend, per SPEC §17.6 — has already shipped in the Enterprisecausl-clientpath, where rust-ssot is the unconditional default; see the Enterprise wasm-performance guide) or it has silently retired one of the four contract surfaces. The 3× floor flips that trade-off into a visible CI failure rather than a silent ship.
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/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 ~11× because mobx returns the new value directly insiderunInActionwithout a shadow-Map probe, andop-commit-rollback-1kreads ~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, not inner-loop cost, and folding them into the residual would produce a number neither side could defend.
The TS engine residual is the contract you measure against Enterprise
The numbers above describe the current TS engine — the default, and the band the §17.5 gate enforces. There is also a projected residual band for the wasm engine, where crossing the native boundary is allowed to bypass the JS structural floor (Map allocation, Object.is dispatch, GC pressure on frozen Commit objects) that the TS engine cannot. Those projections, the backend-selection semantics, and the GO/NO-GO criteria are Enterprise material; the TS-engine band on this page stands on its own.
2. Running the bench harness
The @causl/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 packages/bench/src/run.ts with the default profile, sweeping the seven 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 drives src/report.ts, writes report/benchmark_results.json and report/benchmark_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 / 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–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 regression-baseline.json — are the input you need to decide whether a delta you observe is signal or noise.
Detailed explanation. The harness honestly reports both wall-clock and stddev/CoV because the contract is about median, not 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, not pick the lowest sample. The CI gate intentionally fires on the median.
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 --filter @causl/bench bench --scenarios equality-cutoff,equality-cutoff-fanout-10k,spreadsheet-100x100 --scale 10000
The full audit driver is packages/bench/src/freeze-impact-cli.ts; the smaller-cell entry points share the same harness so the numbers compose with the full report.
2.5 Measurement target for the default engine
The TypeScript floor engine in @causl/core is measured against the §17.5 capability-cost residual band: 3.0×–8.0× mobx_median on the three contract cells. This is the unconditional floor — the numbers in §1 above describe it, and it is the target the bench harness gates against on every run.
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/core';
const graph = createCausl();
const source = graph.input('source', { count: 0 });
const doubled = graph.derived('doubled', g => g(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 — 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 — 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" — mount/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. The TS engine is the default — and the focus of this page
Everything above describes the pure-TypeScript engine that ships in @causl/core by default. It is the unconditional floor: it honours every contract surface, meets the §17.5 residual band, and needs no opt-in, no loader, and no host-capability probe. For the overwhelming majority of application workloads — interactive UIs, forms, moderate spreadsheets — it is the right and only engine you need to reason about.
There is also an opt-in WebAssembly engine for adopters who profile their workload onto the engine inner loop at scale. Because it carries a per-commit marshaling tax across the FFI boundary, it only pays off above a measurable crossover — and that depth (when wasm wins, the marshal tax, the read()-identity hazard, the crossover sweep) is covered in the Enterprise documentation.
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. At that point the lever is the wasm engine Enterprise rather than a micro-optimisation in commit() — see the Enterprise wasm-performance guide for the crossover analysis.
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; 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 of "is this me or is this causl?" is mechanically answerable.
Related reading
- Benchmarks landing page — the canonical seven-cell sweep with live numbers.
- SPEC §17.5 — the capability-cost residual amendment.
- SPEC §17.6 — the host-tier substrate-compatibility commitment.
- Wasm performance & the perf ceiling Enterprise — when wasm pays off, the marshal tax, the
read()-identity hazard, the crossover sweep. - Profiling guide — flame graphs, deopt traces, GC pause distributions.
- Perf-delta template — what to put in a PR body when you touch hot code.