Performance Benchmark
How to Read This Page
Section titled “How to Read This Page”No library is the fastest in every scenario on this page; what sets SynState apart is that, in the summary below, it is never far behind in any of them. Each scenario asks one question about one graph shape, so compare libraries within a scenario, not across them.
| Scenario | Fastest | SynState ÷ fastest | Slowest | Slowest ÷ fastest |
|---|---|---|---|---|
| Single state | RxJS | 1.0× | Jotai | 43× |
| Derived chain | RxJS | 2.0× | Jotai | 103× |
| Diamond | RxJS ※1 | 1.3× | Jotai | 59× |
| Deep chain (K=1000, M=200) | SynState | 1.0× | RxJS | 67× |
| Cascaded diamond (N=20) | Preact Signals | 1.2× | RxJS | > 16,129× |
| Conditional fan-out (B=1000) | Solid | 2.7× ※2 | Redux | 232× |
| Equality cut-off (W=50) | RxJS | 1.2× | Redux | 552× |
In no scenario does SynState take more than 2.7× as long as the fastest library, while the slowest library in each takes at least 43× as long. What each scenario stresses is in its own section.
※1 RxJS is not glitch-free: in a diamond its subscriber also sees a combination of an old and a new value (RxJS glitch). Among the libraries that are, Preact Signals is the fastest.
※2 SynState written with switchMap, which follows the selected branch only, as MobX, Jotai, Solid and Preact Signals do. Written naively, with a derive over every branch, it is 819×; the scenario shows both, the naive one dashed. The naive forms of SynState and RxJS are also left out of the slowest column, as is the equality cut-off’s RxJS row without distinctUntilChanged.
A ratio marked > is a library that did not complete within the timeout (5,000 ms), divided as if it had taken exactly that long.
Read the other way round, the same rows give each library its own worst case: for every library, the scenario where it is furthest from that scenario’s fastest library, and how far.
| Library | Scenario where it trails most | Fastest there | Library ÷ fastest | Per update: library / fastest |
|---|---|---|---|---|
| SynState | Conditional fan-out (B=1000) | Solid | 2.7× | 21 ns / 7.8 ns |
| RxJS | Cascaded diamond (N=20) | Preact Signals | > 16,129× | — |
| MobX | Equality cut-off (W=50) | RxJS | 86× | 6.4 µs / 74 ns |
| Jotai | Equality cut-off (W=50) | RxJS | 265× | 20 µs / 74 ns |
| Redux | Equality cut-off (W=50) | RxJS | 552× | 41 µs / 74 ns |
| Solid | Equality cut-off (W=50) | RxJS | 46× | 3.4 µs / 74 ns |
| Preact Signals | Equality cut-off (W=50) | RxJS | 19× | 1.4 µs / 74 ns |
| Zustand ※3 | Single state | RxJS | 1.3× | 55 ns / 43 ns |
| Valtio ※3 | Single state | RxJS | 11× | 469 ns / 43 ns |
Per update: the time in that row divided by the number of updates the scenario makes, marked > where the library timed out, which makes it a lower bound. Every scenario but the deep chain builds its graph inside the measurement, so the figure includes a share of that; the cascaded diamond makes only 100 updates per graph, so building is most of its time, and it shows none (—). A large ratio can still be a small gap in time, and this column says which it is.
※3 Measured only in the scenarios it can be written in on the same terms as the other libraries, so its worst case is over those alone. Zustand and Valtio have no derived-value node and take part in the single state only (see Keeping the Comparison Fair).
Emphasized: the worst cases that stay in single digits, among the libraries measured in every scenario.
In the conditional fan-out, SynState and RxJS are counted in their switchMap form, as in ※2.
What the measurements show, and how far they go:
- SynState’s per-update overhead is among the lowest. With nothing derived (single state), every library does no more than store a value and call one subscriber, and that is the one scenario where all nine can be compared on equal terms: Zustand takes 1.2× as long as SynState, Preact Signals 1.2×, Solid 6.5×, Valtio 10×, MobX 12×, Redux 27× and Jotai 41×. On the small derived graphs, RxJS, which chains plain
subscribecalls with no ordering step, is faster than SynState in the derived chain (0.5×) and faster than it in the diamond (0.8×); Preact Signals, which reuses each node’s dependency list from one run to the next, is about as fast as SynState in both (1.0× in the derived chain, 1.0× in the diamond); and Solid, MobX, Jotai and Redux are slower. - SynState’s per-update cost does not explode with the shape of the graph. This is what separates it from RxJS, the push-based library here without an ordering step. Every source update visits the nodes it reaches once each, in depth order, and calls each subscriber once, however many paths lead to it, so diamonds add no emissions. Solid, push-based as well, marks every node an update reaches before it reruns any, and so also runs each once. RxJS emits once per path instead: when the deep chain gets four times deeper ( at ), SynState takes 3.6× as long, Solid 4.1×, Preact Signals 6.1×, Jotai 4.1×, MobX 4.0× and Redux 2.6×, but RxJS 17×; and in the cascaded diamond RxJS stops completing at . RxJS’s intermediate emissions are also glitches: subscribers see combinations of old and new values.
- MobX, Jotai and Preact Signals do not recompute a node nobody reads, and Solid does not rerun a memo for a source its last run did not read; SynState recomputes every node it holds. The first three recompute a derived value only when something reads it, so a node nobody reads costs them nothing. Solid’s memos are eager, as SynState’s derived values are, but a memo is subscribed only to what its last run read, so a source it did not read has no observer to notify. SynState evaluates every derived value it holds on every update of its source — the price of every
getSnapshot()being consistent as soon asnextreturns — so what it computes is decided by how the graph is written, not by who reads it. The conditional fan-out is the scenario built for that: aderiveholds a thousand branches that the selector never picks. Written withswitchMap, which subscribes to the selected branch only, SynState stays at ~1.8 ms at every branch count. The unread nodes an application meets are of the same kind, and each has a direct way out of the graph: a branch not selected (switchMap), a view not on screen (create its observables in a scope and dispose it with the view), a value written more often than it is shown (auditorthrottlein front of it). See Evaluation models for how each library schedules this work. - SynState and RxJS stop where a value comes out unchanged; the others first check everything below it. SynState’s
mapand RxJS’sdistinctUntilChangedcompare a value where it is produced, and an equal one is passed on to nothing. Preact Signals, Solid, MobX and Jotai compare too, but a write first marks everything below the source, and every marked node is checked before the equal value is found; Redux calls every subscriber on every dispatch. The equality cut-off is the scenario built for that: one value that rarely changes, with W readers below it. SynState’s and RxJS’s rows stay nearly flat across W, and the others grow with W. - Preact Signals’ worst case is 19× the fastest library, against SynState’s 2.7×. It is pull-based like MobX and Jotai, but a run that reads what it read last time allocates nothing (see its analysis), so on small graphs its per-update overhead is close to SynState’s: the single state, the derived chain and the diamond. Its worst case is where that does not help, a write that marks many readers of a derived value that comes out equal: Equality cut-off (W=50).
- Building a graph costs SynState little. The cascaded diamond creates its graph inside each measurement, and SynState’s row stays flat as grows: a new node calls
addChildon its direct parents and takes its depth as one more than the deepest of them, rather than walking back through the paths to the source. In a graph built once and updated many times, as in the deep chain, construction is not part of the per-update cost at all.
So, in the summary, SynState stays close to the fastest library in every scenario. It is glitch-free, and its per-update cost stays proportional to the size of the graph whatever its shape, as Solid’s and Preact Signals’ do and RxJS’s does not. On the small graphs, its per-update overhead is well below that of Solid, MobX, Jotai and Redux, close to that of Preact Signals, close to that of Zustand in the single state, and at or above that of RxJS. Where it trails most is Conditional fan-out (B=1000), at 2.7× the fastest library there, and that is already the switchMap form, which holds no branch nobody reads: what remains is SynState’s cost of a write that reaches no node. Against Preact Signals, which shares the first two properties, these results name no consistent winner: it takes 1.2× as long as SynState in the single state and 1.2× in the deep chain (, ), and 1.0× in the diamond. Each also has a scenario built for its model: SynState takes 1.7× as long as Preact Signals in the conditional fan-out at B=1000, and Preact Signals 15× as long as SynState in the equality cut-off at W=50. What sets SynState apart from it is its worst case (2.7× the fastest library, against 19×) and the operator set (debounce, throttle, switchMap and more) on the same glitch-free graph (Why SynState?).
Scenario: Single State
Section titled “Scenario: Single State”One state, one subscriber, nothing derived: the floor every other scenario builds on, and the only one that Zustand and Valtio take part in. Their vanilla APIs have no derived-value node, so in a graph with one they could only compute it inline in the subscriber, which is a different, smaller graph than the one the other libraries are asked to build.
- Loop: update counter synchronously from to ().
- Verify: final value .
- Each library’s subscriber records the value it is given, or reads it back from the store where the callback is given none (Jotai, Redux, Valtio). Solid and Preact Signals subscribe with an effect that reads the signal.
Results
Section titled “Results”| Library | Median (ms) | Min (ms) | Max (ms) | p95 (ms) | Ops/sec | vs SynState |
|---|---|---|---|---|---|---|
| SynState | 4.47 | 4.34 | 5.12 | 4.72 | 22,370,156 | 1.00× |
| RxJS | 4.26 | 4.12 | 4.41 | 4.41 | 23,452,501 | 0.95× |
| MobX | 54.94 | 54.65 | 55.56 | 55.26 | 1,820,046 | 12.29× |
| Jotai | 183.23 | 181.66 | 185.21 | 184.69 | 545,767 | 40.99× |
| Redux | 118.90 | 118.23 | 120.38 | 119.76 | 841,033 | 26.60× |
| Solid | 29.05 | 28.73 | 38.71 | 34.29 | 3,441,912 | 6.50× |
| Preact Signals | 5.28 | 5.17 | 5.39 | 5.38 | 18,937,192 | 1.18× |
| Zustand | 5.48 | 5.40 | 6.08 | 5.70 | 18,254,713 | 1.23× |
| Valtio | 46.87 | 46.50 | 47.68 | 47.65 | 2,133,427 | 10.49× |
Implementation Details
Section titled “Implementation Details”All implementations are runnable as vitest tests.
SynState
Section titled “SynState”export const runBenchmark = (n: number): number => { const [counter, setCounter] = createState(0);
let mut_lastValue = 0;
const subscription = counter.subscribe((v) => { mut_lastValue = v; });
for (let mut_i = 1; mut_i <= n; mut_i++) { setCounter(mut_i); }
subscription.unsubscribe();
return mut_lastValue;};export const runBenchmark = (n: number): number => { const counter = new BehaviorSubject(0);
let mut_lastValue = 0;
const subscription = counter.subscribe((v) => { mut_lastValue = v; });
for (let mut_i = 1; mut_i <= n; mut_i++) { counter.next(mut_i); }
subscription.unsubscribe();
return mut_lastValue;};export const runBenchmark = (n: number): number => { const state = observable({ counter: 0 });
let mut_lastValue = 0;
const dispose = reaction( () => state.counter, (value) => { mut_lastValue = value; }, { fireImmediately: true }, );
for (let mut_i = 1; mut_i <= n; mut_i++) { runInAction(() => { state.counter = mut_i; }); }
dispose();
return mut_lastValue;};export const runBenchmark = (n: number): number => { const counterAtom = atom(0);
const store = createStore();
let mut_lastValue = store.get(counterAtom);
const unsub = store.sub(counterAtom, () => { mut_lastValue = store.get(counterAtom); });
for (let mut_i = 1; mut_i <= n; mut_i++) { store.set(counterAtom, mut_i); }
unsub();
return mut_lastValue;};export const runBenchmark = (n: number): number => { const counterSlice = createSlice({ name: 'counter', initialState: { value: 0 }, reducers: { set: (state, action: Readonly<{ payload: number }>) => { state.value = action.payload; }, }, });
const store = configureStore({ reducer: counterSlice.reducer, middleware: () => new Tuple(), });
let mut_lastValue = store.getState().value;
store.subscribe(() => { mut_lastValue = store.getState().value; });
for (let mut_i = 1; mut_i <= n; mut_i++) { store.dispatch(counterSlice.actions.set(mut_i)); }
return mut_lastValue;};export const runBenchmark = (n: number): number => { let mut_lastValue = 0;
const { setCounter, dispose } = createRoot((disposeRoot) => { const [counter, setCounterValue] = createSignal(0);
createEffect(() => { mut_lastValue = counter(); });
return { setCounter: setCounterValue, dispose: disposeRoot }; });
for (let mut_i = 1; mut_i <= n; mut_i++) { setCounter(mut_i); }
dispose();
return mut_lastValue;};Preact Signals
Section titled “Preact Signals”export const runBenchmark = (n: number): number => { const counter = signal(0);
let mut_lastValue = 0;
const dispose = effect(() => { mut_lastValue = counter.value; });
for (let mut_i = 1; mut_i <= n; mut_i++) { counter.value = mut_i; }
dispose();
return mut_lastValue;};Zustand
Section titled “Zustand”export const runBenchmark = (n: number): number => { const store = createStore<Readonly<{ counter: number }>>()(() => ({ counter: 0, }));
let mut_lastValue = store.getState().counter;
store.subscribe((state) => { mut_lastValue = state.counter; });
for (let mut_i = 1; mut_i <= n; mut_i++) { store.setState({ counter: mut_i }); }
return mut_lastValue;};Valtio
Section titled “Valtio”export const runBenchmark = (n: number): number => { const state = proxy({ counter: 0 });
let mut_lastValue = state.counter;
const unsubscribe = subscribe( state, () => { mut_lastValue = state.counter; }, true, );
for (let mut_i = 1; mut_i <= n; mut_i++) { state.counter = mut_i; }
unsubscribe();
return mut_lastValue;};Scenario: Derived Chain
Section titled “Scenario: Derived Chain”A simple linear chain that measures pure overhead of each library’s reactive primitives:
- Loop: update counter synchronously from to ().
- Verify: final value .
- No diamond dependency — pure propagation overhead comparison.
- Zustand and Valtio are left out from this scenario on (they are measured in the single state scenario only). They have no way to build the derived-value nodes (
doubledandquadrupledabove), so they would computecounter * 2 * 2inline in the subscriber, and the graph this scenario is about, one with intermediate nodes, could not be compared.
Results
Section titled “Results”| Library | Median (ms) | Min (ms) | Max (ms) | p95 (ms) | Ops/sec | vs SynState |
|---|---|---|---|---|---|---|
| SynState | 14.60 | 14.19 | 16.14 | 15.53 | 6,848,907 | 1.00× |
| RxJS | 7.30 | 7.22 | 7.93 | 7.43 | 13,707,177 | 0.50× |
| MobX | 109.55 | 109.04 | 110.20 | 110.17 | 912,834 | 7.50× |
| Jotai | 749.44 | 734.09 | 788.52 | 786.44 | 133,433 | 51.33× |
| Redux | 288.67 | 283.74 | 327.16 | 319.22 | 346,421 | 19.77× |
| Solid | 89.30 | 88.81 | 90.31 | 89.61 | 1,119,791 | 6.12× |
| Preact Signals | 13.92 | 13.78 | 14.20 | 14.19 | 7,185,614 | 0.95× |
Implementation Details
Section titled “Implementation Details”Each library implements the same derived chain pattern. All implementations are runnable as vitest tests.
SynState
Section titled “SynState”export const runBenchmark = (n: number): number => { const [counter, setCounter] = createState(0);
const doubled = counter.pipe(map((x) => x * 2));
const quadrupled = doubled.pipe(map((x) => x * 2));
let mut_lastValue = 0;
const subscription = quadrupled.subscribe((v) => { mut_lastValue = v; });
for (let mut_i = 1; mut_i <= n; mut_i++) { setCounter(mut_i); }
subscription.unsubscribe();
return mut_lastValue;};export const runBenchmark = (n: number): number => { const counter = new BehaviorSubject(0);
const doubled = counter.pipe(map((x) => x * 2));
const quadrupled = doubled.pipe(map((x) => x * 2));
let mut_lastValue = 0;
const subscription = quadrupled.subscribe((v) => { mut_lastValue = v; });
for (let mut_i = 1; mut_i <= n; mut_i++) { counter.next(mut_i); }
subscription.unsubscribe();
return mut_lastValue;};export const runBenchmark = (n: number): number => { const state = observable({ counter: 0 });
const doubled = computed(() => state.counter * 2);
const quadrupled = computed(() => doubled.get() * 2);
let mut_lastValue = 0;
const dispose = reaction( () => quadrupled.get(), (value) => { mut_lastValue = value; }, { fireImmediately: true }, );
for (let mut_i = 1; mut_i <= n; mut_i++) { runInAction(() => { state.counter = mut_i; }); }
dispose();
return mut_lastValue;};export const runBenchmark = (n: number): number => { const counterAtom = atom(0);
const doubledAtom = atom((get) => get(counterAtom) * 2);
const quadrupledAtom = atom((get) => get(doubledAtom) * 2);
const store = createStore();
let mut_lastValue = store.get(quadrupledAtom);
const unsub = store.sub(quadrupledAtom, () => { mut_lastValue = store.get(quadrupledAtom); });
for (let mut_i = 1; mut_i <= n; mut_i++) { store.set(counterAtom, mut_i); }
unsub();
return mut_lastValue;};export const runBenchmark = (n: number): number => { const counterSlice = createSlice({ name: 'counter', initialState: { value: 0 }, reducers: { set: (state, action: Readonly<{ payload: number }>) => { state.value = action.payload; }, }, });
const store = configureStore({ reducer: counterSlice.reducer, middleware: () => new Tuple(), });
// eslint-disable-next-line unicorn/consistent-function-scoping const selectCounter = (state: Readonly<{ value: number }>): number => state.value;
// `lruMemoize`, not the default `weakMapMemoize`. Every input here is a new // number that never repeats, and `weakMapMemoize` keeps every distinct // primitive it has seen: one round would cache 100,000 results it never hits, // and that memory and GC would be measured as Redux's cost. const selectDoubled = createSelector( selectCounter, (counter) => counter * 2, { memoize: lruMemoize }, );
const selectQuadrupled = createSelector( selectDoubled, (doubled) => doubled * 2, { memoize: lruMemoize }, );
let mut_lastValue = selectQuadrupled(store.getState());
store.subscribe(() => { mut_lastValue = selectQuadrupled(store.getState()); });
for (let mut_i = 1; mut_i <= n; mut_i++) { store.dispatch(counterSlice.actions.set(mut_i)); }
return mut_lastValue;};export const runBenchmark = (n: number): number => { let mut_lastValue = 0;
const { setCounter, dispose } = createRoot((disposeRoot) => { const [counter, setCounterValue] = createSignal(0);
const doubled = createMemo(() => counter() * 2);
const quadrupled = createMemo(() => doubled() * 2);
createEffect(() => { mut_lastValue = quadrupled(); });
return { setCounter: setCounterValue, dispose: disposeRoot }; });
for (let mut_i = 1; mut_i <= n; mut_i++) { setCounter(mut_i); }
dispose();
return mut_lastValue;};Preact Signals
Section titled “Preact Signals”export const runBenchmark = (n: number): number => { const counter = signal(0);
const doubled = computed(() => counter.value * 2);
const quadrupled = computed(() => doubled.value * 2);
let mut_lastValue = 0;
const dispose = effect(() => { mut_lastValue = quadrupled.value; });
for (let mut_i = 1; mut_i <= n; mut_i++) { counter.value = mut_i; }
dispose();
return mut_lastValue;};Scenario: Diamond Dependency
Section titled “Scenario: Diamond Dependency”A diamond-shaped dependency graph that tests how each library handles multiple derived values merging back into one:
- Loop: update counter synchronously from to ().
- Verify: final value .
- Zustand and Valtio are excluded because their vanilla APIs have no mechanism to define separate derived values and combine them.
Results
Section titled “Results”| Library | Median (ms) | Min (ms) | Max (ms) | p95 (ms) | Ops/sec | vs SynState |
|---|---|---|---|---|---|---|
| SynState | 24.29 | 23.26 | 24.95 | 24.87 | 4,117,334 | 1.00× |
| RxJS | 18.48 | 18.34 | 18.84 | 18.75 | 5,412,556 | 0.76× |
| MobX | 140.71 | 139.99 | 141.89 | 141.48 | 710,694 | 5.79× |
| Jotai | 1096.53 | 1088.62 | 1147.77 | 1123.97 | 91,197 | 45.15× |
| Redux | 360.89 | 357.51 | 367.38 | 365.38 | 277,094 | 14.86× |
| Solid | 107.27 | 106.73 | 108.01 | 107.82 | 932,258 | 4.42× |
| Preact Signals | 23.57 | 23.23 | 25.74 | 24.37 | 4,243,028 | 0.97× |
Implementation Details
Section titled “Implementation Details”SynState
Section titled “SynState”export const runBenchmark = (n: number): number => { const [counter, setCounter] = createState(0);
const doubled = counter.pipe(map((x) => x * 2));
const tripled = counter.pipe(map((x) => x * 3));
const sum = derive([doubled, tripled], (d, t) => d + t);
let mut_lastValue = 0;
const subscription = sum.subscribe((v) => { mut_lastValue = v; });
for (let mut_i = 1; mut_i <= n; mut_i++) { setCounter(mut_i); }
subscription.unsubscribe();
return mut_lastValue;};export const runBenchmark = (n: number): number => { const counter = new BehaviorSubject(0);
const doubled = counter.pipe(map((x) => x * 2));
const tripled = counter.pipe(map((x) => x * 3));
const sum = combineLatest([doubled, tripled]).pipe(map(([d, t]) => d + t));
let mut_lastValue = 0;
const subscription = sum.subscribe((v) => { mut_lastValue = v; });
for (let mut_i = 1; mut_i <= n; mut_i++) { counter.next(mut_i); }
subscription.unsubscribe();
return mut_lastValue;};export const runBenchmark = (n: number): number => { const state = observable({ counter: 0 });
const doubled = computed(() => state.counter * 2);
const tripled = computed(() => state.counter * 3);
const sum = computed(() => doubled.get() + tripled.get());
let mut_lastValue = 0;
const dispose = reaction( () => sum.get(), (value) => { mut_lastValue = value; }, { fireImmediately: true }, );
for (let mut_i = 1; mut_i <= n; mut_i++) { runInAction(() => { state.counter = mut_i; }); }
dispose();
return mut_lastValue;};export const runBenchmark = (n: number): number => { const counterAtom = atom(0);
const doubledAtom = atom((get) => get(counterAtom) * 2);
const tripledAtom = atom((get) => get(counterAtom) * 3);
const sumAtom = atom((get) => get(doubledAtom) + get(tripledAtom));
const store = createStore();
let mut_lastValue = store.get(sumAtom);
const unsub = store.sub(sumAtom, () => { mut_lastValue = store.get(sumAtom); });
for (let mut_i = 1; mut_i <= n; mut_i++) { store.set(counterAtom, mut_i); }
unsub();
return mut_lastValue;};export const runBenchmark = (n: number): number => { const counterSlice = createSlice({ name: 'counter', initialState: { value: 0 }, reducers: { set: (state, action: Readonly<{ payload: number }>) => { state.value = action.payload; }, }, });
const store = configureStore({ reducer: counterSlice.reducer, middleware: () => new Tuple(), });
// eslint-disable-next-line unicorn/consistent-function-scoping const selectCounter = (state: Readonly<{ value: number }>): number => state.value;
// `lruMemoize`, not the default `weakMapMemoize`. Every input here is a new // number that never repeats, and `weakMapMemoize` keeps every distinct // primitive it has seen: one round would cache 100,000 results it never hits, // and that memory and GC would be measured as Redux's cost. const selectDoubled = createSelector( selectCounter, (counter) => counter * 2, { memoize: lruMemoize }, );
const selectTripled = createSelector( selectCounter, (counter) => counter * 3, { memoize: lruMemoize }, );
const selectSum = createSelector( selectDoubled, selectTripled, (doubled, tripled) => doubled + tripled, { memoize: lruMemoize }, );
let mut_lastValue = selectSum(store.getState());
store.subscribe(() => { mut_lastValue = selectSum(store.getState()); });
for (let mut_i = 1; mut_i <= n; mut_i++) { store.dispatch(counterSlice.actions.set(mut_i)); }
return mut_lastValue;};export const runBenchmark = (n: number): number => { let mut_lastValue = 0;
const { setCounter, dispose } = createRoot((disposeRoot) => { const [counter, setCounterValue] = createSignal(0);
const doubled = createMemo(() => counter() * 2);
const tripled = createMemo(() => counter() * 3);
const sum = createMemo(() => doubled() + tripled());
createEffect(() => { mut_lastValue = sum(); });
return { setCounter: setCounterValue, dispose: disposeRoot }; });
for (let mut_i = 1; mut_i <= n; mut_i++) { setCounter(mut_i); }
dispose();
return mut_lastValue;};Preact Signals
Section titled “Preact Signals”export const runBenchmark = (n: number): number => { const counter = signal(0);
const doubled = computed(() => counter.value * 2);
const tripled = computed(() => counter.value * 3);
const sum = computed(() => doubled.value + tripled.value);
let mut_lastValue = 0;
const dispose = effect(() => { mut_lastValue = sum.value; });
for (let mut_i = 1; mut_i <= n; mut_i++) { counter.value = mut_i; }
dispose();
return mut_lastValue;};Scenario: Deep Chain Throughput
Section titled “Scenario: Deep Chain Throughput”The first two scenarios use fixed-size graphs. This scenario matches the Throughput interactive demo: a depth- scan chain where each stage lerps toward the previous stage, with a combine that reads all outputs. source updates are pushed synchronously per measurement — simulating updates per animation frame.
The following diagram shows the graph structure at . In the actual benchmark, ranges from to :
- (updates per measurement): , , .
- (chain depth): , , .
- Zustand and Valtio are excluded, as from every scenario that derives a value (see Keeping the Comparison Fair).
- Redux has no
scaneither: a selector cannot read its own previous output, so each stage’s position is state, and one reducer advances the whole chain, as MobX’sreactiondoes (see Keeping the Comparison Fair). Preact Signals’computedis not given its previous value either, so its stages are signals that one effect advances in a batch, as MobX’s are. - Solid’s
createMemois given its own previous value, which isscan’s accumulator, so its chain is memos, a chain of derived nodes like SynState’s, RxJS’s and Jotai’s. - Measurements exceeding 5,000 ms are aborted.
Results
Section titled “Results”| Library | K=100, M=50 | K=500, M=50 | K=1000, M=50 | K=100, M=100 | K=500, M=100 | K=1000, M=100 | K=500, M=200 | K=1000, M=200 |
|---|---|---|---|---|---|---|---|---|
| SynState | 1.3 ms | 2.6 ms | 4.1 ms | 0.7 ms | 3.9 ms | 7.5 ms | 7.4 ms | 14.8 ms |
| RxJS | 9.7 ms | 30.3 ms | 58.2 ms | 22.5 ms | 134.2 ms | 241.6 ms | 492.3 ms | 981.5 ms |
| Jotai | 22.7 ms | 100.0 ms | 194.3 ms | 40.0 ms | 195.0 ms | 390.9 ms | 401.3 ms | 802.7 ms |
| MobX | 17.3 ms | 80.9 ms | 159.3 ms | 31.8 ms | 158.2 ms | 320.8 ms | 318.6 ms | 638.5 ms |
| Redux | 0.8 ms | 2.4 ms | 5.7 ms | 0.9 ms | 1.9 ms | 6.3 ms | 2.6 ms | 14.8 ms |
| Solid | 3.6 ms | 6.7 ms | 13.4 ms | 2.4 ms | 12.6 ms | 28.0 ms | 28.0 ms | 55.0 ms |
| Preact Signals | 1.3 ms | 1.7 ms | 2.9 ms | 0.5 ms | 3.1 ms | 8.7 ms | 8.8 ms | 17.8 ms |
What the Results Show
Section titled “What the Results Show”- SynState scales linearly with both and — at , , it finishes in 14.8 ms. Each stage runs once per update, in depth order, with no dependency to track again.
- RxJS takes 982 ms at , — 67× slower than SynState. As explained above, this is not a constant-factor difference but a quadratic asymptotic cost from
combineLatest’s redundant emissions. The same blowup is visible in the interactive demo. - Jotai takes 803 ms at , — 54× slower than SynState — due to
selectAtom’s per-update overhead (epoch-based recomputation and dynamic dependency tracking on every propagation step). - MobX takes 639 ms at , — 43× slower than SynState. This scenario requires
scan(stateful accumulation: each stage needs its own previous value plus the new input). MobX’scomputedcannot express this because it is a pure derivation with no access to its own previous output. The only option isobservable.box+reaction(push-based), which incurs per-stage overhead from proxy tracking, dependency notification, andautorunre-reads. In contrast, the cascaded diamond scenario uses stateless derivations thatcomputedcan handle efficiently via lazy evaluation. - Redux takes 14.8 ms at , — 1.0× as long as SynState. Its chain is one loop in the reducer over plain objects, followed by one selector over the result, with no node per stage to track or notify, so across the sweep its cost stays within a small factor of SynState’s. What it adds per update is a dispatch, a new state object and a new array of points. Like MobX’s row, it measures imperative code rather than a chain of derived nodes.
- Solid takes 55.0 ms at , — 3.7× as long as SynState. Its memos are a chain of derived nodes, each run once per update, so it grows linearly as SynState does; what it adds per stage is the memo’s rerun bookkeeping (see Solid’s analysis).
- Preact Signals takes 17.8 ms at , — 1.2× as long as SynState. Like MobX’s, its row measures an imperative loop over signals inside one batch, followed by one effect that reads all ; unlike MobX, re-tracking those reads on every run allocates nothing.
- At , — a realistic scenario for 60fps animation with moderate graph complexity — SynState (3.9 ms), Redux (1.9 ms), Preact Signals (3.1 ms) and Solid (12.6 ms) fit within the 16ms frame time, while RxJS (134 ms), Jotai (195 ms), and MobX (158 ms) all far exceed it.
Scenario: Cascaded Diamond
Section titled “Scenario: Cascaded Diamond”This scenario demonstrates the exponential blowup of RxJS’s combineLatest when diamonds are chained in series. Each stage splits the previous output into two branches (fan-out ) and recombines them, creating a chain of binary diamonds:
The diagram shows . In the benchmark, ranges from to .
- (source updates): .
- (cascade depth): .
- Measurements exceeding 5,000 ms are aborted.
Results
Section titled “Results”| Library | N=2 | N=4 | N=6 | N=8 | N=10 | N=12 | N=14 | N=16 | N=18 | N=20 |
|---|---|---|---|---|---|---|---|---|---|---|
| SynState | 0.4 ms | 0.4 ms | 0.6 ms | 0.6 ms | 0.6 ms | 0.7 ms | 0.5 ms | 0.3 ms | 0.3 ms | 0.4 ms |
| RxJS | 0.5 ms | 1.0 ms | 4.2 ms | 25.6 ms | 131.9 ms | 745.5 ms | > 5000 ms | > 5000 ms | > 5000 ms | > 5000 ms |
| Jotai | 3.7 ms | 5.5 ms | 6.3 ms | 8.1 ms | 12.8 ms | 10.0 ms | 11.9 ms | 13.4 ms | 15.0 ms | 20.9 ms |
| MobX | 0.8 ms | 1.1 ms | 1.2 ms | 1.0 ms | 0.8 ms | 0.8 ms | 1.0 ms | 1.1 ms | 1.6 ms | 1.3 ms |
| Redux | 1.6 ms | 1.4 ms | 1.9 ms | 3.0 ms | 1.8 ms | 3.1 ms | 3.6 ms | 2.8 ms | 3.1 ms | 4.8 ms |
| Solid | 0.3 ms | 0.5 ms | 1.2 ms | 2.0 ms | 1.1 ms | 0.8 ms | 1.0 ms | 1.1 ms | 1.2 ms | 1.4 ms |
| Preact Signals | 0.2 ms | 0.3 ms | 0.4 ms | 0.4 ms | 0.4 ms | 0.5 ms | 0.2 ms | 0.3 ms | 0.3 ms | 0.3 ms |
What the Results Show
Section titled “What the Results Show”- RxJS exhibits classic exponential growth (binary fan-out). It times out at ( emissions per source update updates). At , it already takes 746 ms — 1,096× slower than SynState (0.7 ms).
- MobX is still only 1.3 ms at , thanks to its lazy
computedevaluation. MobX’scomputedvalues are not eagerly re-evaluated when their dependencies change — they are only recomputed when read. Thereactionreads only the finalcomputed, which triggers a single lazy pass through the chain. Eachcomputedin the chain is evaluated exactly once per update, yielding total work with minimal overhead. This contrasts sharply with the deep chain throughput scenario where MobX is 43× slower than SynState — because that scenario uses a chain ofreactions (push-based), notcomputeds (pull-based). The performance characteristics depend heavily on which MobX primitive is used. - SynState’s row stays flat across the sweep — a median of 0.4 ms over the three smallest and 0.3 ms over the three largest — although each
runBenchmarkcall also builds the graph. An update visits each of the observables once, in depth order, and calls the subscriber once. A new observable callsaddChildon its direct parents and takes its depth as one more than the deepest of them, so building the graph never walks the paths back to the source. The circular-dependency check, which visits each ancestor once, runs only outside production and so is not measured here. - Jotai’s row grows across the sweep — a median of 5.5 ms over the three smallest and 15.0 ms over the three largest. An update recomputes each of the derived atoms once, as SynState’s does, but every recomputation goes through the store’s bookkeeping — tracking what the atom read, invalidating what reads it — and that cost per atom is large enough for the term to show. At it is ~55× slower than SynState.
- Solid and Preact Signals stay at or below 2.0 ms at every — a median of 0.5 ms over the three smallest and 1.2 ms over the three largest for Solid, 0.3 ms and 0.3 ms for Preact Signals. Both run each of the derived nodes once per update: Solid by marking every node below the write before it reruns any, Preact Signals by recomputing a
computedonly when it is read and one of its sources has a newer version. - Redux’s row stays nearly flat across the sweep — a median of 1.6 ms over the three smallest and 3.1 ms over the three largest; over , the median of its time against SynState’s is 5.4×. Its
createSelectorchain is pulled by the subscriber, as MobX’scomputedis, and each selector, memoized on its inputs, recomputes once per update: selector calls, each of which first compares its arguments with the previous ones.
Scenario: Conditional Fan-Out
Section titled “Scenario: Conditional Fan-Out”The previous scenarios test propagation throughput — every source update reaches the subscriber. This scenario tests a different pattern: the cost of updating an observable that is not in the subscription set — the case where dynamic dependency tracking is most advantageous, and a scenario for seeing how the evaluation model of MobX and Jotai behaves.
The graph has B branches and a selector, a source that says which branch the result reads: the result is branch[selector]. The selector is fixed at throughout the benchmark, so only branch[0] is “active”. The benchmark updates branch[1] (an inactive branch) times.
The key difference between libraries is whether branch[1] has any observers (subscribers):
- SynState and RxJS with
switchMap(the “(switchMap)” rows):selector.pipe(switchMap((sel) => branches[sel]))subscribes to the selected branch only, and to another one when the selector changes, sobranch[1]has no observer, as in the dynamic subscriptions below. This is how SynState and RxJS express a dynamic dependency. - The same two written naively, as static subscriptions (the “(naive …)” rows, dashed in the chart): a
derive(SynState) orcombineLatest(RxJS) over the selector and every branch subscribes to all branches at construction time, sobranch[1]has an observer. Whenbranch[1]is updated, the node fires, the projection produces the same value as before, and equality comparison prevents downstream propagation. Cost scales with B because every update reads all B branches. These rows are here to show what holding a branch nobody reads costs, not as a way either library is meant to be written. - Dynamic subscriptions (Jotai, MobX, Solid, Preact Signals): The
computed, memo or derived atom only subscribes to observables that were actually read during its last run. Whenselector = 0, onlybranch[0].get()is called, sobranch[1]’s observer list is empty. Updatingbranch[1]has nothing to notify — nothing happens. - One store (Redux): there is no per-branch subscription to have or not have. Every dispatch runs the reducer and calls every subscriber, whichever branch it changed, and the subscriber’s selector reads the selected branch only. The write itself copies the
branchesarray, so part of the cost grows with B.
Results
Section titled “Results”| Library | B=2 | B=5 | B=10 | B=20 | B=50 | B=100 | B=200 | B=500 | B=1000 |
|---|---|---|---|---|---|---|---|---|---|
| SynState (switchMap) | 2.1 ms | 2.0 ms | 1.6 ms | 1.7 ms | 1.7 ms | 1.7 ms | 1.8 ms | 5.9 ms | 2.1 ms |
| SynState (naive derive) | 11.4 ms | 12.1 ms | 14.9 ms | 20.3 ms | 37.9 ms | 68.4 ms | 131.6 ms | 310.2 ms | 638.6 ms |
| RxJS (switchMap) | 3.4 ms | 2.9 ms | 2.8 ms | 2.9 ms | 2.8 ms | 2.9 ms | 3.0 ms | 3.1 ms | 3.2 ms |
| RxJS (naive combineLatest) | 8.5 ms | 8.3 ms | 8.7 ms | 9.1 ms | 10.0 ms | 10.8 ms | 17.2 ms | 30.2 ms | 57.8 ms |
| Jotai | 137.7 ms | 138.6 ms | 138.7 ms | 137.3 ms | 137.5 ms | 137.5 ms | 136.1 ms | 138.6 ms | 138.2 ms |
| MobX | 1.5 ms | 1.5 ms | 1.4 ms | 1.4 ms | 1.4 ms | 1.4 ms | 1.4 ms | 1.5 ms | 1.5 ms |
| Redux | 136.2 ms | 133.8 ms | 134.8 ms | 118.7 ms | 125.1 ms | 130.8 ms | 133.5 ms | 150.8 ms | 180.8 ms |
| Solid | 1.0 ms | 0.7 ms | 0.7 ms | 0.7 ms | 0.7 ms | 0.7 ms | 0.7 ms | 0.7 ms | 0.8 ms |
| Preact Signals | 1.1 ms | 1.0 ms | 1.1 ms | 1.0 ms | 0.9 ms | 1.1 ms | 1.1 ms | 1.1 ms | 1.3 ms |
The dashed lines are the naive forms. SynState’s own line is the solid “SynState (switchMap)”.
What the Results Show
Section titled “What the Results Show”-
MobX, Solid and Preact Signals follow what is read.
computed(() => branches[selector.get()].get())subscribes only toselectorandbranches[0]at runtime, sobranches[1]’s observer list is empty. Updatingbranches[1]has no one to notify, so their rows stay flat across B: ~1.4 ms for MobX, ~0.7 ms for Solid and ~1.1 ms for Preact Signals. What is left is each library’s cost of a write with no observer: a comparison of the value, plus a version bump in Preact Signals. -
SynState with
switchMapstays at ~1.8 ms regardless of B, and RxJS withswitchMapat ~2.9 ms: an update ofbranch[1]reaches no node in either. In SynState it does not open an update at all:branch[1]has no child and no subscriber, so the write stores its value and returns, as a write to an unobserved signal does in Solid. -
SynState written naively grows linearly with B because its
derivesubscribes to all branches at construction time, so any branch update fires it. The dashed line shows the trend: from 11.4 ms at B=2 to 639 ms at B=1000 (≈6 ns per branch per update, over updates). What this row measures is thederiveover every branch it was written with, not a limit of the model. -
RxJS written naively uses the same static approach with
combineLatest. Its cost also scales with B but more slowly than SynState’s naive form, becausecombineLatestdoes not perform equality comparison — it always emits, and the subscriber does minimal work (assigning a value). -
Jotai uses dynamic subscriptions like MobX —
resultAtomdoes not subscribe to the inactive branch, so no recomputation is triggered. However,store.set()itself performs bookkeeping operations (invalidation checks) inside the atom store, producing a constant overhead of ~138 ms regardless of B. This makes Jotai slower than even SynState’s naive form for B≤200. -
Redux takes 136 ms at B=2 and 181 ms at B=1000. Most of it is the dispatch and the subscriber call every update pays whatever B is; what grows with B is the copy of the
branchesarray.
Implementation details
Section titled “Implementation details”SynState (switchMap)
Section titled “SynState (switchMap)”export const runBenchmark = (k: number, branchCount: number): number => { const [selector] = createState(0);
const branches: DeepReadonly< { source: Observable<number>; set: (v: number) => void; }[] > = Arr.zeros(asUint32(branchCount)).map(() => { const [source, setSource] = createState(0);
return { source, set: setSource }; });
// Dynamic dependency: only follows the branch selected by selector const result = selector.pipe( switchMap((sel) => branches[sel]?.source ?? just(0)), );
let mut_lastValue = 0;
const subscription = result.subscribe((v) => { mut_lastValue = v; });
// Update an INACTIVE branch (branch 1, while selector = 0) const inactiveBranch = branches[1];
if (inactiveBranch === undefined) { throw new Error('need at least 2 branches'); }
for (let mut_i = 1; mut_i <= k; mut_i++) { inactiveBranch.set(mut_i); }
subscription.unsubscribe();
return mut_lastValue;};SynState (naive derive)
Section titled “SynState (naive derive)”export const runBenchmark = (k: number, branchCount: number): number => { const [selector] = createState(0);
const branches: DeepReadonly< { source: Observable<number>; set: (v: number) => void; }[] > = Arr.zeros(asUint32(branchCount)).map(() => { const [source, setSource] = createState(0);
return { source, set: setSource }; });
// `as const` needs a literal, and `derive` needs the tuple type only the // literal produces. // eslint-disable-next-line ts-data-forge/prefer-canonical-array-slicing const allSources = [ selector, ...branches.map((b) => b.source), ] as const satisfies NonEmptyTuple<Observable<number>>;
const result = derive( allSources, (selectorValue, ...branchesValue) => branchesValue[selectorValue] ?? 0, );
let mut_lastValue = 0;
const subscription = result.subscribe((v) => { mut_lastValue = v; });
// Update an INACTIVE branch (branch 1, while selector = 0) const inactiveBranch = branches[1];
if (inactiveBranch === undefined) { throw new Error('need at least 2 branches'); }
for (let mut_i = 1; mut_i <= k; mut_i++) { inactiveBranch.set(mut_i); }
subscription.unsubscribe();
return mut_lastValue;};RxJS (switchMap)
Section titled “RxJS (switchMap)”export const runBenchmark = (k: number, branchCount: number): number => { const selector = new BehaviorSubject(0);
const branches: readonly BehaviorSubject<number>[] = Arr.zeros( asUint32(branchCount), ).map(() => new BehaviorSubject(0));
// Dynamic dependency: only follows the branch selected by selector const result = selector.pipe(switchMap((sel) => branches[sel] ?? of(0)));
let mut_lastValue = 0;
const subscription = result.subscribe((v) => { mut_lastValue = v; });
// Update an INACTIVE branch (branch 1, while selector = 0) const inactiveBranch = branches[1];
if (inactiveBranch === undefined) { throw new Error('need at least 2 branches'); }
for (let mut_i = 1; mut_i <= k; mut_i++) { inactiveBranch.next(mut_i); }
subscription.unsubscribe();
return mut_lastValue;};RxJS (naive combineLatest)
Section titled “RxJS (naive combineLatest)”export const runBenchmark = (k: number, branchCount: number): number => { const selector = new BehaviorSubject(0);
const branches: readonly BehaviorSubject<number>[] = Arr.zeros( asUint32(branchCount), ).map(() => new BehaviorSubject(0));
// `combineLatest` only infers the element types from an array literal. // eslint-disable-next-line ts-data-forge/prefer-canonical-array-slicing const result = combineLatest([selector, ...branches]).pipe( map((values) => { const sel = values[0];
return values[sel + 1] ?? 0; }), );
let mut_lastValue = 0;
const subscription = result.subscribe((v) => { mut_lastValue = v; });
// Update an INACTIVE branch (branch 1, while selector = 0) const inactiveBranch = branches[1];
if (inactiveBranch === undefined) { throw new Error('need at least 2 branches'); }
for (let mut_i = 1; mut_i <= k; mut_i++) { inactiveBranch.next(mut_i); }
subscription.unsubscribe();
return mut_lastValue;};export const runBenchmark = (k: number, branchCount: number): number => { const selector = observable.box(0);
const branches: readonly ReturnType<typeof observable.box<number>>[] = Arr.zeros(asUint32(branchCount)).map(() => observable.box(0));
// Dynamic dependency: only tracks the branch selected by selector const result = computed(() => { const sel = selector.get();
const target = branches[sel];
return target === undefined ? 0 : target.get(); });
let mut_lastValue = 0;
const dispose = reaction( () => result.get(), (value) => { mut_lastValue = value ?? 0; }, { fireImmediately: true }, );
// Update an INACTIVE branch (branch 1, while selector = 0) const inactiveBranch = branches[1];
if (inactiveBranch === undefined) { throw new Error('need at least 2 branches'); }
for (let mut_i = 1; mut_i <= k; mut_i++) { inactiveBranch.set(mut_i); }
dispose();
return mut_lastValue;};export const runBenchmark = (k: number, branchCount: number): number => { const selectorAtom = atom(0);
const branchAtoms: readonly WritableAtom< number, Mutable<readonly [number]>, void >[] = Arr.zeros(asUint32(branchCount)).map(() => atom(0));
// Dynamic dependency: only reads the branch selected by selectorAtom const resultAtom = atom((get) => { const sel = get(selectorAtom);
const targetAtom = branchAtoms[sel];
return targetAtom === undefined ? 0 : get(targetAtom); });
const store = createStore();
let mut_lastValue = store.get(resultAtom);
const unsub = store.sub(resultAtom, () => { mut_lastValue = store.get(resultAtom); });
// Update an INACTIVE branch (branch 1, while selector = 0) const inactiveBranchAtom = branchAtoms[1];
if (inactiveBranchAtom === undefined) { throw new Error('need at least 2 branches'); }
for (let mut_i = 1; mut_i <= k; mut_i++) { store.set(inactiveBranchAtom, mut_i); }
unsub();
return mut_lastValue;};export const runBenchmark = (k: number, branchCount: number): number => { type State = Readonly<{ selector: number; branches: readonly number[]; }>;
const setBranch = createAction<Readonly<{ index: number; value: number }>>( 'fanOut/setBranch', );
const isSetBranch = isAnyOf(setBranch);
const initialState: State = { selector: 0, branches: Arr.zeros(asUint32(branchCount)), } as const;
// A plain reducer rather than `createSlice`: an Immer draft of the `branches` // array would be finalized element by element on every update, and that would // be measured as Redux's cost. const store = configureStore({ reducer: ( state: State | undefined, action: Readonly<UnknownAction>, ): State => { const current = state ?? initialState;
return isSetBranch(action) ? { ...current, branches: current.branches.with( action.payload.index, action.payload.value, ), } : current; }, middleware: () => new Tuple(), });
// Reads only the branch the selector picks. Every update replaces the // `branches` array, so this recomputes on each one — an index lookup. const selectResult = createSelector( (state: State) => state.selector, (state: State) => state.branches, (sel, branches) => branches[sel] ?? 0, { memoize: lruMemoize }, );
let mut_lastValue = selectResult(store.getState());
store.subscribe(() => { mut_lastValue = selectResult(store.getState()); });
// Update an INACTIVE branch (branch 1, while selector = 0) for (let mut_i = 1; mut_i <= k; mut_i++) { store.dispatch(setBranch({ index: 1, value: mut_i })); }
return mut_lastValue;};export const runBenchmark = (k: number, branchCount: number): number => { let mut_lastValue = 0;
const { branches, dispose } = createRoot((disposeRoot) => { const [selector] = createSignal(0);
const branchSignals: readonly Signal<number>[] = Arr.zeros( asUint32(branchCount), ).map(() => createSignal(0));
// Dynamic dependency: only tracks the branch selected by selector const result = createMemo(() => { const target = branchSignals[selector()];
return target === undefined ? 0 : target[0](); });
createEffect(() => { mut_lastValue = result(); });
return { branches: branchSignals, dispose: disposeRoot }; });
// Update an INACTIVE branch (branch 1, while selector = 0) const inactiveBranch = branches[1];
if (inactiveBranch === undefined) { throw new Error('need at least 2 branches'); }
const [, setInactiveBranch] = inactiveBranch;
for (let mut_i = 1; mut_i <= k; mut_i++) { setInactiveBranch(mut_i); }
dispose();
return mut_lastValue;};Preact Signals
Section titled “Preact Signals”export const runBenchmark = (k: number, branchCount: number): number => { const selector = signal(0);
const branches: readonly Signal<number>[] = Arr.zeros( asUint32(branchCount), ).map(() => signal(0));
// Dynamic dependency: only tracks the branch selected by selector const result = computed(() => { const target = branches[selector.value];
return target === undefined ? 0 : target.value; });
let mut_lastValue = 0;
const dispose = effect(() => { mut_lastValue = result.value; });
// Update an INACTIVE branch (branch 1, while selector = 0) const inactiveBranch = branches[1];
if (inactiveBranch === undefined) { throw new Error('need at least 2 branches'); }
for (let mut_i = 1; mut_i <= k; mut_i++) { inactiveBranch.value = mut_i; }
dispose();
return mut_lastValue;};Scenario: Equality Cut-Off
Section titled “Scenario: Equality Cut-Off”This scenario measures an update whose intermediate value comes out equal to the previous one, so that everything below it could be skipped — a derived “is the list empty” flag under a list that changes on every keystroke, read by many components.
The source is written times. bucket is Math.floor(source / 1000), so it changes on one update in 1000 and is equal to its previous value on the rest. Below it are W views, view[i] = bucket + i, each with a subscriber of its own.
Every library here, except the naive RxJS row, compares a derived value and stops at an equal one. What differs is how much of the graph below bucket an update reaches before the comparison stops it:
- Compared where the value is produced (SynState, RxJS): SynState’s
mapcompares its result withObject.is, and RxJS’sdistinctUntilChangedcompares the value it passes on. An equalbucketemits nothing, so no view is reached. RxJS compares only wheredistinctUntilChangedis written, so it is also measured without it (the “(naive …)” row, dashed in the chart), where every update reaches every view. - Marked, then pulled (Preact Signals, Solid, MobX, Jotai): a write marks what lies below the source as possibly stale —
bucket, each view and each subscriber — beforebucketis recomputed. Each view is then checked, findsbucketunchanged and keeps its value, so no subscriber runs; but every view has been visited, on every update. - One store (Redux): every dispatch calls every subscriber, one per view as each
useSelectoris. Each subscriber’s selector findsbucketunchanged and returns its cached result.
Results
Section titled “Results”| Library | W=1 | W=2 | W=5 | W=10 | W=20 | W=50 | W=100 | W=200 | W=500 | W=1000 |
|---|---|---|---|---|---|---|---|---|---|---|
| SynState | 9.1 ms | 9.5 ms | 9.2 ms | 8.7 ms | 8.8 ms | 9.2 ms | 9.4 ms | 11.4 ms | 12.4 ms | 16.3 ms |
| RxJS | 7.4 ms | 7.5 ms | 7.1 ms | 7.3 ms | 7.2 ms | 7.4 ms | 7.7 ms | 8.6 ms | 10.8 ms | 15.6 ms |
| RxJS (naive, no distinctUntilChanged) | 17.6 ms | 20.0 ms | 30.1 ms | 48.3 ms | 92.3 ms | 185.2 ms | 341.4 ms | 688.0 ms | 1848.1 ms | 3896.0 ms |
| Jotai | 447.4 ms | 492.6 ms | 562.1 ms | 703.0 ms | 993.5 ms | 1963.5 ms | 3710.2 ms | > 5000 ms | > 5000 ms | > 5000 ms |
| MobX | 66.3 ms | 78.9 ms | 114.5 ms | 173.8 ms | 287.4 ms | 638.5 ms | 1154.6 ms | 2243.6 ms | > 5000 ms | > 5000 ms |
| Redux | 282.3 ms | 358.6 ms | 574.5 ms | 920.6 ms | 1777.9 ms | 4093.0 ms | > 5000 ms | > 5000 ms | > 5000 ms | > 5000 ms |
| Solid | 46.0 ms | 50.6 ms | 66.8 ms | 94.4 ms | 157.5 ms | 340.2 ms | 654.2 ms | 1295.0 ms | 3469.1 ms | > 5000 ms |
| Preact Signals | 7.6 ms | 10.2 ms | 17.9 ms | 31.1 ms | 58.0 ms | 139.3 ms | 282.0 ms | 584.8 ms | 1585.9 ms | 3345.8 ms |
The dashed line is RxJS written without distinctUntilChanged. A row that stops before W=1000 timed out at its next point.
What the Results Show
Section titled “What the Results Show”-
SynState and RxJS stay nearly flat across W: a median of 9.2 ms over the three narrowest W and 12.4 ms over the three widest for SynState, and 7.4 ms and 10.8 ms for RxJS. An equal
bucketpasses nothing on, so the views run only on the updates wherebucketchanges. -
Preact Signals, Solid, MobX, Jotai and Redux grow with W, because every update reaches every view, whether
bucketchanged or not. Each row’s slope is what one more view adds to one update:- Preact Signals: 7.6 ms at W=1, 3,346 ms at W=1000, about 33 ns per view.
- Solid: 46.0 ms at W=1, 3,469 ms at W=500, about 69 ns per view.
- MobX: 66.3 ms at W=1, 2,244 ms at W=200, about 109 ns per view.
- Jotai: 447 ms at W=1, 3,710 ms at W=100, about 330 ns per view.
- Redux: 282 ms at W=1, 4,093 ms at W=50, about 778 ns per view.
-
RxJS without
distinctUntilChangedgrows with W as well: 17.6 ms at W=1, 3,896 ms at W=1000, about 39 ns per view, 250× as long as RxJS with it there. In RxJS the cut-off is the operator, not the model.
Implementation details
Section titled “Implementation details”SynState
Section titled “SynState”export const runBenchmark = (k: number, width: number): number => { const [source, setSource] = createState(0);
// Equal to its previous value on 999 updates in 1000. `map` compares its // result with `Object.is` and passes nothing on when it is equal. const bucket = source.pipe(map((v) => Math.floor(v / 1000)));
const mut_lastValues = Arr.zeros(asUint32(width)).map(() => 0);
const subscriptions = Arr.seq(asUint32(width)).map((i) => bucket.pipe(map((b) => b + i)).subscribe((v) => { mut_lastValues[i] = v; }), );
for (let mut_i = 1; mut_i <= k; mut_i++) { setSource(mut_i); }
for (const subscription of subscriptions) { subscription.unsubscribe(); }
return Arr.sum(mut_lastValues);};RxJS (distinctUntilChanged)
Section titled “RxJS (distinctUntilChanged)”export const runBenchmark = (k: number, width: number): number => { const source = new BehaviorSubject(0);
// Equal to its previous value on 999 updates in 1000, which // `distinctUntilChanged` drops. `shareReplay` makes it one node for every // view, as a derived value is in the other libraries; without it each // subscription would run its own `map`. const bucket = source.pipe( map((v) => Math.floor(v / 1000)), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }), );
const mut_lastValues = Arr.zeros(asUint32(width)).map(() => 0);
const subscriptions = Arr.seq(asUint32(width)).map((i) => bucket.pipe(map((b) => b + i)).subscribe((v) => { mut_lastValues[i] = v; }), );
for (let mut_i = 1; mut_i <= k; mut_i++) { source.next(mut_i); }
for (const subscription of subscriptions) { subscription.unsubscribe(); }
return Arr.sum(mut_lastValues);};RxJS (naive, without distinctUntilChanged)
Section titled “RxJS (naive, without distinctUntilChanged)”export const runBenchmark = (k: number, width: number): number => { const source = new BehaviorSubject(0);
// The same graph without `distinctUntilChanged`: the bucket is emitted on // every update, equal or not, and every view runs. const bucket = source.pipe( map((v) => Math.floor(v / 1000)), shareReplay({ bufferSize: 1, refCount: true }), );
const mut_lastValues = Arr.zeros(asUint32(width)).map(() => 0);
const subscriptions = Arr.seq(asUint32(width)).map((i) => bucket.pipe(map((b) => b + i)).subscribe((v) => { mut_lastValues[i] = v; }), );
for (let mut_i = 1; mut_i <= k; mut_i++) { source.next(mut_i); }
for (const subscription of subscriptions) { subscription.unsubscribe(); }
return Arr.sum(mut_lastValues);};export const runBenchmark = (k: number, width: number): number => { const state = observable({ source: 0 });
// Equal to its previous value on 999 updates in 1000. A `computed` compares // its result and does not change when it is equal. const bucket = computed(() => Math.floor(state.source / 1000));
const mut_lastValues = Arr.zeros(asUint32(width)).map(() => 0);
const disposers = Arr.seq(asUint32(width)).map((i) => { const view = computed(() => bucket.get() + i);
return reaction( () => view.get(), (v) => { mut_lastValues[i] = v; }, { fireImmediately: true }, ); });
for (let mut_i = 1; mut_i <= k; mut_i++) { runInAction(() => { state.source = mut_i; }); }
for (const dispose of disposers) { dispose(); }
return Arr.sum(mut_lastValues);};export const runBenchmark = (k: number, width: number): number => { const sourceAtom = atom(0);
// Equal to its previous value on 999 updates in 1000. A derived atom compares // its result with `Object.is` and does not change when it is equal. const bucketAtom = atom((get) => Math.floor(get(sourceAtom) / 1000));
const store = createStore();
const mut_lastValues = Arr.zeros(asUint32(width)).map(() => 0);
const disposers = Arr.seq(asUint32(width)).map((i) => { const viewAtom = atom((get) => get(bucketAtom) + i);
mut_lastValues[i] = store.get(viewAtom);
return store.sub(viewAtom, () => { mut_lastValues[i] = store.get(viewAtom); }); });
for (let mut_i = 1; mut_i <= k; mut_i++) { store.set(sourceAtom, mut_i); }
for (const dispose of disposers) { dispose(); }
return Arr.sum(mut_lastValues);};export const runBenchmark = (k: number, width: number): number => { const counterSlice = createSlice({ name: 'counter', initialState: { value: 0 }, reducers: { set: (state, action: Readonly<{ payload: number }>) => { state.value = action.payload; }, }, });
const store = configureStore({ reducer: counterSlice.reducer, middleware: () => new Tuple(), });
// eslint-disable-next-line unicorn/consistent-function-scoping const selectCounter = (state: Readonly<{ value: number }>): number => state.value;
// Equal to its previous value on 999 updates in 1000, so each view's // selector, which compares its input with the previous one, returns its // cached result. `lruMemoize` for the reason the derived chain gives. const selectBucket = createSelector( selectCounter, (counter) => Math.floor(counter / 1000), { memoize: lruMemoize }, );
const mut_lastValues = Arr.zeros(asUint32(width)).map(() => 0);
// One subscriber per view, as each `useSelector` is: the store calls every // one of them on every dispatch. const disposers = Arr.seq(asUint32(width)).map((i) => { const selectView = createSelector(selectBucket, (b) => b + i, { memoize: lruMemoize, });
mut_lastValues[i] = selectView(store.getState());
return store.subscribe(() => { mut_lastValues[i] = selectView(store.getState()); }); });
for (let mut_i = 1; mut_i <= k; mut_i++) { store.dispatch(counterSlice.actions.set(mut_i)); }
for (const dispose of disposers) { dispose(); }
return Arr.sum(mut_lastValues);};export const runBenchmark = (k: number, width: number): number => { const mut_lastValues = Arr.zeros(asUint32(width)).map(() => 0);
const { setSource, dispose } = createRoot((disposeRoot) => { const [source, setSourceValue] = createSignal(0);
// Equal to its previous value on 999 updates in 1000. A memo compares its // result with `===` and does not change when it is equal. const bucket = createMemo(() => Math.floor(source() / 1000));
for (const i of range(0, asSafeUint(width))) { const view = createMemo(() => bucket() + i);
createEffect(() => { mut_lastValues[i] = view(); }); }
return { setSource: setSourceValue, dispose: disposeRoot }; });
for (let mut_i = 1; mut_i <= k; mut_i++) { setSource(mut_i); }
dispose();
return Arr.sum(mut_lastValues);};Preact Signals
Section titled “Preact Signals”export const runBenchmark = (k: number, width: number): number => { const source = signal(0);
// Equal to its previous value on 999 updates in 1000. A `computed` compares // its result and keeps its version when it is equal. const bucket = computed(() => Math.floor(source.value / 1000));
const mut_lastValues = Arr.zeros(asUint32(width)).map(() => 0);
const disposers = Arr.seq(asUint32(width)).map((i) => { const view = computed(() => bucket.value + i);
return effect(() => { mut_lastValues[i] = view.value; }); });
for (let mut_i = 1; mut_i <= k; mut_i++) { source.value = mut_i; }
for (const dispose of disposers) { dispose(); }
return Arr.sum(mut_lastValues);};Methodology
Section titled “Methodology”- Warmup: 5 rounds (discarded).
- Measured: 20 rounds.
- : updates per round.
- Timing:
performance.now()(sub-millisecond precision). - Isolation: each row is measured in a Node process of its own. V8 optimizes a function for the shapes it has seen, so in a shared process a row inherits what the rows before it taught it, and its figure depends on which ran first.
- Builds: every library, SynState included, runs its production build (
NODE_ENV=production), which is what an application ships, so development-only checks are not measured. The exception is Valtio, whose ES module build readsimport.meta.env.MODE, which a bundler defines and Node does not; its few development checks run when a subscription is made or a nested proxy is attached, not on each update. - Statistics: median, min, max, p95, ops/sec (based on median), and for the first three scenarios “vs SynState”, each median divided by SynState’s.
Keeping the Comparison Fair
Section titled “Keeping the Comparison Fair”- Redux: Default middleware (thunk, serializable check, immutability check) is disabled via
middleware: () => new Tuple(). These are development-time checks that are tree-shaken in production builds. - Redux (Deep Chain, Conditional Fan-Out): the reducer is a plain function matched against a
createAction, not acreateSlicereducer. An Immer draft of points or branches would be created and finalized element by element on every update, and that would measure Immer rather than the store. The other scenarios keepcreateSlice, where the state is one number. - Redux (Deep Chain): a selector cannot read its own previous output, so the
scanchain is the reducer’s loop over each stage’s stored position. Like MobX’s, that is imperative code, and Redux’s row measures it as such. - Zustand and Valtio (Single State only): neither vanilla API has a derived-value node (Valtio’s
deriveis a separate package), so in the other scenarios they could only compute the derived value inline in the subscriber — fewer nodes than the graph the other libraries build, and so not the same measurement. They take part only where no library derives anything. For Valtio, the third argumenttruetosubscribemakes notification synchronous, so that the subscriber fires on every mutation (Valtio batches asynchronously by default). - RxJS (Diamond, Cascaded Diamond, and Conditional Fan-Out’s naive row):
combineLatestproduces a tuple that a followingmapturns into the value, so each merge is two nodes where SynState’sderive, MobX’scomputedand Jotai’s derived atom are one. That costs it one node and one array per merge, and it is how RxJS writes a merge. - MobX (Deep Chain): a
computedcannot read its own previous output, so thescanchain is written asobservable.boxes that onereactionupdates in a loop inside a single action. That is imperative code, not a chain of derived nodes, and MobX’s row measures it as such. - RxJS (Diamond Dependency):
combineLatestfires the subscriber twice per source update due to the glitch problem. The benchmark measures wall-clock time including this extra work, which reflects the real-world cost of diamond dependencies in RxJS. - Solid: the scenarios import
solid-js/dist/solid.js, the build a bundler picks for the browser. The root export ofsolid-jsresolves to its server build under Node’snodecondition, where a memo is computed once and an effect never runs, so in this Node benchmark it would measure nothing. The functions are the same ones an application imports fromsolid-js. Every graph is created insidecreateRoot, which is what lets it be disposed, and the subscriber is acreateEffect. Solid 1.9 is measured: Solid 2.0, whose core is published on its own as@solidjs/signals, was a release candidate when this was written. - Solid (Deep Chain): the
scanstages are memos given their previous value (createMemo(fn, initialValue)), Solid’s own form of an accumulator. - Preact Signals:
@preact/signals-core, the package with no Preact dependency. The subscriber is aneffect;signal.subscribe(fn), which passes the value, is an effect underneath. - Preact Signals (Deep Chain): a
computedis not given its previous value, so thescanchain is signals that one effect advances inside abatch, reading each stage withpeek, as MobX’sreactiondoes. Acomputedthat kept its previous value in a closure would also work here, because every stage is read on every update, but it relies on how often the library evaluates it, and MobX’s row was not written that way either. - MobX:
reactionis used instead ofautorunto match other libraries’ subscribe-and-record pattern.runInActionwraps each mutation as required by MobX strict mode. - Jotai:
store.sub()does not pass the current value to the callback, so an additionalstore.get()call is made inside the listener. The initial value is also read viastore.get()before subscribing. - Except in the deep chain, all libraries create their state, derived values, and subscriptions inside the benchmark function, so setup cost is included in the measurement. The deep chain builds its graph before the timer starts.
- RxJS (Equality Cut-Off):
bucketis shared withshareReplay({ bufferSize: 1, refCount: true }), so that it is one node for every view, as a derived value is in the other libraries. Without it each subscription would run amapof its own, and withshare()a view subscribed after the first would not see the current value. - Redux (Equality Cut-Off): one
store.subscribeper view, as eachuseSelectorsubscribes, each reading its view through acreateSelectorover one sharedbucketselector. - Conditional Fan-Out: SynState and RxJS are measured twice, naively with a static
derive/combineLatestover every branch (the “(naive …)” rows) and withswitchMap, which subscribes to the selected branch only, as MobX, Jotai, Solid and Preact Signals do.
Analysis: What Makes Each Library Slow
Section titled “Analysis: What Makes Each Library Slow”Every library on this page is slow somewhere, and each for a reason in the work it does on every update. This section describes that work as read from each library’s source: what it buys the library, what it costs, and the scenario where the cost shows most, which is the library’s row in the per-library table. What SynState does on an update is stepped through in Tracing an Update.
RxJS: Every Path Emits, So Merges Multiply the Work
Section titled “RxJS: Every Path Emits, So Merges Multiply the Work”RxJS does little per update. next calls each subscriber directly, depth first, with no ordering step and no bookkeeping, which is why it is among the fastest libraries in the single state, the derived chain and the diamond. What it does not do is wait. combineLatest emits as soon as any one of its inputs does, so a node that paths reach emits times per source update, and everything below it runs each time. That is the of the deep chain and the of the cascaded diamond, RxJS’s worst row. Every one of those emissions except the last is a glitch.
MobX: Every Run Tracks Its Dependencies Again
Section titled “MobX: Every Run Tracks Its Dependencies Again”A write is an assignment to an observable inside runInAction: a box.set, or a property of an observable object, which sets the box behind it. The action opens a batch, the set compares the value and marks every observer stale (propagateChanged), and when the batch closes each stale reaction runs again. Every run is tracked: MobX allocates a new dependency array, records each .get() the run makes, then diffs the result against the previous run’s list (bindDependencies) and adds or removes observers where they differ. That fixed cost is MobX’s 12× in the single state.
In the deep chain, the scan stages are observable.boxes set one by one inside one action, and the autorun that reads all of them runs again on every update and re-tracks dependencies. Where nothing needs to run — a computed nobody reads, a branch nobody observes — MobX does no work at all, which is why its conditional fan-out row stays flat across B, at ~1.4 ms, and why in the cascaded diamond, where the reaction reads only the last computed, it stays close to SynState as grows, typically 2.2× its time (the median over ). A node that is read but does not change is another matter. In the equality cut-off, MobX’s worst row, a write marks every view below bucket possibly stale (propagateMaybeChanged), and every reaction checks its view before bucket is found unchanged, about 109 ns per view per update.
Jotai: Every set Resolves the Graph Again
Section titled “Jotai: Every set Resolves the Graph Again”Jotai is glitch-free, and its cost is per-update framework overhead: work that Jotai’s store performs on every store.set() call, and again for every atom the call recomputes. It shows most where the other libraries have least to do: in the conditional fan-out, a write to a branch nobody reads costs MobX, Solid and Preact Signals 15 ns or less and Jotai a whole store.set(), 1.4 µs. Its worst row is Equality cut-off (W=50), where each store.set() runs steps 2 and 3 below over every view under bucket, and step 4 then finds bucket unchanged: about 330 ns per view per update.
Per-update cost breakdown
Section titled “Per-update cost breakdown”When store.set(counterAtom, i) is called, Jotai’s store executes these steps:
- Value update — write the new value and compare with
Object.is. . - Invalidation pass (
invalidateDependents) — DFS traversal of all mounted dependents, marking each as “needs recomputation” via epoch number tracking in aMap<Atom, EpochNumber>. where = number of affected atoms. - Topological sort (
recomputeInvalidatedAtoms) — a second DFS post-order traversal usingWeakSetfor visit tracking, producing a correctly ordered list of atoms to recompute. . - Recomputation — for each atom in topological order, execute its
readfunction. Inside the read function, eachget(dep)call: (a) looks up the dependency’sAtomState, (b) compares epoch numbers to check staleness, (c) records the dependency in aMapfor future invalidation. The cost is — the sum of direct dependencies across all recomputed atoms, not the product. In a chain of N atoms each with 1 dependency, this is . - Subscriber notification — fire listener callbacks. Jotai’s
store.sub()does not pass the new value, so the callback must callstore.get()again, triggering an additional cache lookup. per listener, but adds one extra traversal.
Comparison with SynState
Section titled “Comparison with SynState”| Step | SynState | Jotai |
|---|---|---|
| Graph resolution | Depths fixed at construction; an update queues by depth | Every store.set() (steps 2–3) |
| Propagation | Direct function calls, children queued by the node that emits | read function execution with getter spy + Map/WeakSet operations |
| Dependency tracking | Static (set at construction time) | Dynamic (rediscovered on every evaluation via getter spy) |
| Staleness check | Not needed (push-based) | Epoch number comparison per get() call |
| Subscriber value delivery | Value passed directly to callback | Callback must call store.get() to read the value |
Where the constant-factor difference is visible
Section titled “Where the constant-factor difference is visible”The benchmark loop runs 100,000 updates to make the per-update difference measurable. But these constant-factor costs also matter in real applications with high-frequency events.
For example, mousemove and pointermove events fire dozens of times per second. If each event triggers a reactive pipeline — such as a drag-and-drop handler, a tooltip position, or a canvas animation — the per-update overhead is multiplied by the event rate. The Throughput demo runs a deeper chain of this kind many times per frame and shows how long each library takes per frame, measured in your browser, against the 16 ms budget of a 60 fps frame.
The root cause of the per-update gap:
- SynState:
setCounter(i)→ calldoubled’s map function → callquadrupled’s map function → call subscriber. Three function calls, with one bucket push per node and one queue object per update as the only bookkeeping. - Jotai:
store.set(counterAtom, i)→ DFS invalidation (WeakSet alloc + Map writes) → DFS topological sort (WeakSet alloc + array push + reverse) → recomputedoubledAtom(Map lookup + epoch check + Map write) → recomputequadrupledAtom(same) → flush callback →store.get(quadrupledAtom)(cache lookup).
Over 100,000 iterations, SynState’s three function calls dominate, while Jotai’s per-update bookkeeping (Map/WeakSet allocations, epoch comparisons, two DFS traversals) accumulates into the observed ~51× difference.
Redux: Every Dispatch Calls Every Subscriber
Section titled “Redux: Every Dispatch Calls Every Subscriber”A dispatch runs these steps, in the order Redux and Redux Toolkit run them:
- Action — the action creator allocates
{ type, payload }, anddispatchchecks that it is a plain object with a stringtype. - Reducer — the root reducer runs for every action. A
createSlicereducer runs inside Immer’sproduce: Immer creates a draft proxy, records the write on it and finalizes the draft into a new state object. On the one-number state of the scenarios that use it, that is most of what the reducer costs. - Notification — every subscriber is called, whatever the action changed. The store keeps no record of which subscriber reads what.
- Selectors — each subscriber reads the state back through its selectors. A
createSelectorselector first looks its argument up in a cache of its own (weakMapMemoizeby default, which adds an entry for every new state object), runs its input selectors into a new array, and compares their results with the previous ones before it calls its result function.
The conditional fan-out is all four steps and nothing else: writing a branch nobody selected still runs the reducer, calls the subscriber and runs its selector, already about 136 ms at the smallest branch count, where MobX, Solid and Preact Signals, whose subscriptions follow what is read, do nothing. The equality cut-off, Redux’s worst row, repeats steps 3 and 4 for every view: each dispatch calls all W subscribers, and each selector compares its input before it returns its cached result, about 778 ns per view per update. What the other libraries build as nodes is, in Redux, plain code — a reducer loop in the deep chain, a chain of selectors in the cascaded diamond — so in the deep chain Redux stays within a small factor of SynState, and in the cascaded diamond it trails SynState, by a median of 5.4× over .
Solid: Every Rerun Registers Its Dependencies Again
Section titled “Solid: Every Rerun Registers Its Dependencies Again”A setter compares the new value (=== by default) and, if the signal has observers, opens an update (runUpdates), which allocates two queues, one for memos and one for effects. Every observer is queued and marked stale, and everything below it marked pending (markDownstream). The memo queue then runs; a memo whose upstream is still stale runs that first (runTop, lookUpstream), which is how a diamond’s merge sees both sides updated, and effects run last. A rerun begins by removing the computation from every source its last run read (cleanNode), and each source is registered again as the function reads it, so dependencies are rediscovered on every run, as in MobX. Memos are eager: a memo reruns when a source changes whether or not anything reads it.
The two queues per write and the removal and re-registration per rerun are fixed costs, and they are Solid’s 6.5× in the single state and 6.1× in the derived chain. Its worst row is Equality cut-off (W=50), where a write marks every view below bucket pending (markDownstream) and each is looked over (lookUpstream) before bucket is found unchanged. A write to a signal nobody observes skips the update altogether, which is why Solid’s conditional fan-out row stays flat across B, at ~0.7 ms. SynState skips it the same way, for a write to an observable with no child and no subscriber; what separates the two there is the cost of that write.
Preact Signals: Cheap Reruns, but a Write Marks Every Reader
Section titled “Preact Signals: Cheap Reruns, but a Write Marks Every Reader”A write to .value compares with !==, increments the signal’s version and a global version, and notifies its targets inside a batch: a computed is flagged outdated and passes the notification on, and an effect is queued. When the outermost batch ends, each queued effect checks whether any of its sources has a newer version than the one it last saw (needsToRecompute) and reruns. Reading a computed refreshes it the same way, recomputing only when a source’s version moved, so a diamond’s merge is computed once, after both sides — glitch-free by pull, as in MobX and Jotai.
Dependencies are tracked on every run here too, but a node’s links to its sources are a linked list that a rerun marks and reuses (prepareSources, cleanupSources): a run that reads what the last one read allocates nothing and touches no subscriber list. That is why its row is close to SynState’s in the single state (1.2×), the derived chain (1.0×) and the diamond (1.0×). Its worst row is Equality cut-off (W=50), where a write flags every view below bucket outdated and queues its effect, and each effect checks its view before bucket is found unchanged, about 33 ns per view per update.
Zustand and Valtio
Section titled “Zustand and Valtio”Both are measured in the single state only. Zustand’s setState merges the change into a new object (Object.assign) and calls each listener with it, little more than SynState does: 1.2×. A Valtio write goes through its proxy’s set trap, which compares the old value, wraps an object value in a proxy of its own, bumps a version number and records the operation; the listener subscribe registers then receives the operations as an array: 10×.