---
title: Benchmarks
description: Measured performance — precise re-renders beat full-tree updates 3x at 1,000 rows, atom writes in nanoseconds, and every claim is pinned by a render-count test and a CI regression gate.
group: Advanced
order: 5
---

effract's performance story is the one the types already tell: **a state change re-renders exactly the
components that read it** — so update cost tracks what changed, not how big the tree is. The numbers
below measure that, against plain React doing the same job, on the same DOM.

Three things keep these numbers honest:

- **Production builds, both sides.** The suite runs React and effract production paths (no dev ledger,
  no `act`); every comparison renders identical DOM, and only how state reaches components differs.
- **Render counts are pinned as tests.** Every "1 re-render" claim is an exact assertion in the
  [guarantee suite](https://github.com/get-tmonier/effract/blob/main/bench/src/render-counts.test.tsx),
  verified against the dev render ledger — numbers vary by machine; counts are not allowed to.
- **CI gates.** Every push runs the suite against
  [thresholds](https://github.com/get-tmonier/effract/blob/main/bench/thresholds.json): relative gates
  (effract vs the plain-React baseline *in the same run*, so they hold on any machine) and generous
  absolute floors. A regression fails the build.

Reference machine: Apple M3 Pro, Node 26, React 19.2, Effect 4.0 beta. Reproduce with `just bench`.

## Updates — where precision pays

One store write, and how much of the tree pays for it. The plain-React baselines are the standard
patterns: state lifted to the list owner, with and without `memo` rows.

| scenario | effract | plain React | advantage |
| --- | --- | --- | --- |
| update 1 row of 1,000 | **0.12 ms** — 1 re-render | 0.42 ms — 1,001 re-renders | **3.4x** |
| update 1 row of 1,000 (memo rows) | **0.12 ms** — 1 re-render | 0.40 ms — 1,000 prop compares | **3.3x** |
| update a leaf at depth 100 | **0.027 ms** — 1 re-render | 0.050 ms — 101 re-renders (prop drilling) | **1.9x** |
| burst of 100 writes under one derived reader | **0.009 ms** with `batch()` — 1 recompute | 0.15 ms unbatched — 100 recomputes | **16x** |

The row update is the shape that matters: each row `yield*`s its own `atomFamily` member, so writing one
atom re-renders one `<li>` — the other 999 rows and the table component are untouched (that sentence is
[a test](https://github.com/get-tmonier/effract/blob/main/bench/src/render-counts.test.tsx), attributed
by the render ledger to `rows(42)`). The gap widens with tree size — effract's update cost is flat while
the baseline's grows with the tree.

Where does the remaining 0.12 ms go? We profiled it: a control row built with nothing but React's own
`useSyncExternalStore` — the same precise-subscription shape, zero effract code — costs 0.066 ms in the
same table, and one `useContext` read on the rows (which any store library's rows have) raises that to
0.107 ms. React's wide-list bookkeeping and context scan are ~85% of the cost; the interpreter's own
margin is ~0.02 ms. **In real Chrome** (see methodology) the same update measures **0.086 ms vs 0.173 ms
plain React — 2.0x** — the happy-dom ratios transfer.

## The signal graph — nanoseconds

The reactive core a component subscribes to, measured with no React and no DOM:

| operation | throughput |
| --- | --- |
| `atom.set` — no subscribers | **31M/s** |
| `atom.set` — 1 subscriber | **26M/s** |
| `atom.set` — 1,000 subscribers | **~350k/s** (~3 µs a wave) |
| `atom.value` read | **38M/s** |
| memoised derived read | **32M/s** |
| write through a chain of 10 derives | **2.2M/s** |
| write through a chain of 100 derives | **160k/s** |
| write fanning out to 100 derives | **190k/s** |
| `atomFamily` lookup among 1,000 members | **40M/s** |

## The query cache — reload without remount

`cache.invalidate(tag)` and `cache.refetch(key)` revalidate in the background — readers keep showing
the current value, then update, with no Suspense fallback and no remount:

| scenario | round trip |
| --- | --- |
| `invalidate(tag)` → 100 refetches → all 100 readers re-committed | **≈5.8 ms** |
| `refetch(key)` → 1 refetch → 1 reader re-committed | **≈5.8 ms** |

(~5 ms of both is the harness's DOM-settle poll floor — the cache work itself is the difference.)
Dedup is a guarantee, not a benchmark: 50 readers of one keyed query cause exactly **1 fetch**, and a
100-row list keeps all 100 entries live and invalidatable — both pinned in the guarantee suite.

## Mount and SSR — the overhead you pay once

Precision costs something at mount: the interpreter drives a generator per component, and `mount`
boots a runtime. That price, measured:

| scenario | effract | plain React | overhead |
| --- | --- | --- | --- |
| mount a 500-component tree | 5.3 ms | 3.7 ms | **1.4x** |
| mount boundary (runtime boot, one component) | 0.37 ms | 0.046 ms | +0.3 ms fixed |
| stream a 500-component page (`renderToStream`) | 0.70 ms | 0.30 ms (`react-dom` raw) | **2.3x** |
| stream a page awaiting 20 queries inline | **0.44 ms** | — | — |

The 1.4x is per-mount, paid once (a `yield*` of a service resolves by direct context lookup — ~25 ns); updates after it are where the table above takes over. The SSR
figure includes what raw `react-dom` doesn't do: building the per-request runtime, awaiting queries
inline, and embedding the settled state that makes client hydration automatic — 1,435 full pages/s on
the reference machine. Per-request isolation is nearly free: passing a pre-built shared runtime instead
of a `Layer` (both are supported by `renderToStream`) is only ~4% faster — the fresh-runtime default
costs almost nothing.

## Application shapes — islands, virtualized lists, dev mode

| scenario | measured |
| --- | --- |
| a page of 20 islands, each `mount(layer, …)` with its own runtime | **1.2 ms** total |
| the same 20 islands sharing one pre-built runtime (`mount(runtime, …)`) | **0.97 ms** — 1.2x |
| virtualized scroll: shift a 50-row window by 10 (recycled slots) | **0.12 ms** a step — ~8,400 steps/s |

Per-island runtimes cost ~15 µs each — share a runtime for the semantics (one query cache, one set of
services across islands), not for speed. The virtualized step is 3.7x plain React's recycled slots in
relative terms, and ~70x faster than a 120 Hz scroll needs in absolute terms.

**Dev mode:** React's own development build dominates development performance — the plain-React control
is ~5x slower in dev than in production. effract's ledger and render attribution add nothing measurable
on top: effract's margin over plain React is the same ~0.06 ms per update in dev and prod.

## Methodology, in one paragraph

The suite is [`bench/`](https://github.com/get-tmonier/effract/tree/main/bench) in the repo:
[tinybench](https://github.com/tinylibs/tinybench) via `vitest bench`, production `NODE_ENV` for both
libraries, happy-dom for DOM benches (both sides of every comparison pay the same environment costs),
synchronous work driven by `flushSync`, async work polled to a settled DOM. Alongside the timings run
the render-count guarantee tests — exact, ledger-attributed render counts for every published claim —
and CI enforces both on every push: counts exactly, timings against thresholds. For a real-browser
check, `pnpm --filter @effract/bench bench:browser` bundles the update scenarios with production React
and runs them in your installed Chrome — the ratios above hold there (2.0x on the 1,000-row update,
21x on the batched burst).
