Skip to content

Performance Benchmark

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.

ScenarioFastestSynState ÷ fastestSlowestSlowest ÷ fastest
Single stateRxJS1.0×Jotai43×
Derived chainRxJS2.0×Jotai103×
DiamondRxJS ※11.3×Jotai59×
Deep chain (K=1000, M=200)SynState1.0×RxJS67×
Cascaded diamond (N=20)Preact Signals1.2×RxJS> 16,129×
Conditional fan-out (B=1000)Solid2.7× ※2Redux232×
Equality cut-off (W=50)RxJS1.2×Redux552×

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.

LibraryScenario where it trails mostFastest thereLibrary ÷ fastestPer update: library / fastest
SynStateConditional fan-out (B=1000)Solid2.7×21 ns / 7.8 ns
RxJSCascaded diamond (N=20)Preact Signals> 16,129×—
MobXEquality cut-off (W=50)RxJS86×6.4 µs / 74 ns
JotaiEquality cut-off (W=50)RxJS265×20 µs / 74 ns
ReduxEquality cut-off (W=50)RxJS552×41 µs / 74 ns
SolidEquality cut-off (W=50)RxJS46×3.4 µs / 74 ns
Preact SignalsEquality cut-off (W=50)RxJS19×1.4 µs / 74 ns
Zustand ※3Single stateRxJS1.3×55 ns / 43 ns
Valtio ※3Single stateRxJS11×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 subscribe calls 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 (M=50→200M = 50 \to 200 at K=1000K = 1000), 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 N=14N=14. 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 as next returns — 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: a derive holds a thousand branches that the selector never picks. Written with switchMap, 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 (audit or throttle in 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 map and RxJS’s distinctUntilChanged compare 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 NN grows: a new node calls addChild on its direct parents and takes its depth as one more than the deepest of them, rather than walking back through the 2N2^N 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 (K=1000K=1000, M=200M=200), 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?).

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 11 to NN (N=105N = 10^5).
  • Verify: final value =N= N.
  • 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.
LibraryMedian (ms)Min (ms)Max (ms)p95 (ms)Ops/secvs SynState
SynState4.474.345.124.7222,370,1561.00×
RxJS4.264.124.414.4123,452,5010.95×
MobX54.9454.6555.5655.261,820,04612.29×
Jotai183.23181.66185.21184.69545,76740.99×
Redux118.90118.23120.38119.76841,03326.60×
Solid29.0528.7338.7134.293,441,9126.50×
Preact Signals5.285.175.395.3818,937,1921.18×
Zustand5.485.406.085.7018,254,7131.23×
Valtio46.8746.5047.6847.652,133,42710.49×

All implementations are runnable as vitest tests.

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;
};
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;
};
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;
};
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;
};

A simple linear chain that measures pure overhead of each library’s reactive primitives:

  • Loop: update counter synchronously from 11 to NN (N=105N = 10^5).
  • Verify: final value =N×4= N \times 4.
  • 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 (doubled and quadrupled above), so they would compute counter * 2 * 2 inline in the subscriber, and the graph this scenario is about, one with intermediate nodes, could not be compared.
LibraryMedian (ms)Min (ms)Max (ms)p95 (ms)Ops/secvs SynState
SynState14.6014.1916.1415.536,848,9071.00×
RxJS7.307.227.937.4313,707,1770.50×
MobX109.55109.04110.20110.17912,8347.50×
Jotai749.44734.09788.52786.44133,43351.33×
Redux288.67283.74327.16319.22346,42119.77×
Solid89.3088.8190.3189.611,119,7916.12×
Preact Signals13.9213.7814.2014.197,185,6140.95×

Each library implements the same derived chain pattern. All implementations are runnable as vitest tests.

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;
};
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;
};

A diamond-shaped dependency graph that tests how each library handles multiple derived values merging back into one:

  • Loop: update counter synchronously from 11 to NN (N=105N = 10^5).
  • Verify: final value =N×5= N \times 5.
  • Zustand and Valtio are excluded because their vanilla APIs have no mechanism to define separate derived values and combine them.
LibraryMedian (ms)Min (ms)Max (ms)p95 (ms)Ops/secvs SynState
SynState24.2923.2624.9524.874,117,3341.00×
RxJS18.4818.3418.8418.755,412,5560.76×
MobX140.71139.99141.89141.48710,6945.79×
Jotai1096.531088.621147.771123.9791,19745.15×
Redux360.89357.51367.38365.38277,09414.86×
Solid107.27106.73108.01107.82932,2584.42×
Preact Signals23.5723.2325.7424.374,243,0280.97×
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;
};
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;
};

The first two scenarios use fixed-size graphs. This scenario matches the Throughput interactive demo: a depth-MM scan chain where each stage lerps toward the previous stage, with a combine that reads all M+1M+1 outputs. KK source updates are pushed synchronously per measurement — simulating KK updates per animation frame.

The following diagram shows the graph structure at M=3M = 3. In the actual benchmark, MM ranges from 5050 to 200200:

  • KK (updates per measurement): 100100, 500500, 10001000.
  • MM (chain depth): 5050, 100100, 200200.
  • Zustand and Valtio are excluded, as from every scenario that derives a value (see Keeping the Comparison Fair).
  • Redux has no scan either: a selector cannot read its own previous output, so each stage’s position is state, and one reducer advances the whole chain, as MobX’s reaction does (see Keeping the Comparison Fair). Preact Signals’ computed is not given its previous value either, so its stages are signals that one effect advances in a batch, as MobX’s are.
  • Solid’s createMemo is given its own previous value, which is scan’s accumulator, so its chain is MM memos, a chain of derived nodes like SynState’s, RxJS’s and Jotai’s.
  • Measurements exceeding 5,000 ms are aborted.
LibraryK=100, M=50K=500, M=50K=1000, M=50K=100, M=100K=500, M=100K=1000, M=100K=500, M=200K=1000, M=200
SynState1.3 ms2.6 ms4.1 ms0.7 ms3.9 ms7.5 ms7.4 ms14.8 ms
RxJS9.7 ms30.3 ms58.2 ms22.5 ms134.2 ms241.6 ms492.3 ms981.5 ms
Jotai22.7 ms100.0 ms194.3 ms40.0 ms195.0 ms390.9 ms401.3 ms802.7 ms
MobX17.3 ms80.9 ms159.3 ms31.8 ms158.2 ms320.8 ms318.6 ms638.5 ms
Redux0.8 ms2.4 ms5.7 ms0.9 ms1.9 ms6.3 ms2.6 ms14.8 ms
Solid3.6 ms6.7 ms13.4 ms2.4 ms12.6 ms28.0 ms28.0 ms55.0 ms
Preact Signals1.3 ms1.7 ms2.9 ms0.5 ms3.1 ms8.7 ms8.8 ms17.8 ms
  • SynState scales linearly with both KK and MM — at K=1000K=1000, M=200M=200, 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 K=1000K=1000, M=200M=200 — 67× slower than SynState. As explained above, this is not a constant-factor difference but a quadratic O(M2)O(M^2) asymptotic cost from combineLatest’s redundant emissions. The same blowup is visible in the interactive demo.
  • Jotai takes 803 ms at K=1000K=1000, M=200M=200 — 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 K=1000K=1000, M=200M=200 — 43× slower than SynState. This scenario requires scan (stateful accumulation: each stage needs its own previous value plus the new input). MobX’s computed cannot express this because it is a pure derivation with no access to its own previous output. The only option is observable.box + reaction (push-based), which incurs per-stage overhead from proxy tracking, dependency notification, and autorun re-reads. In contrast, the cascaded diamond scenario uses stateless derivations that computed can handle efficiently via lazy evaluation.
  • Redux takes 14.8 ms at K=1000K=1000, M=200M=200 — 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 M+1M+1 points. Like MobX’s row, it measures imperative code rather than a chain of derived nodes.
  • Solid takes 55.0 ms at K=1000K=1000, M=200M=200 — 3.7× as long as SynState. Its MM 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 K=1000K=1000, M=200M=200 — 1.2× as long as SynState. Like MobX’s, its row measures an imperative loop over MM signals inside one batch, followed by one effect that reads all M+1M+1; unlike MobX, re-tracking those M+1M+1 reads on every run allocates nothing.
  • At K=500K=500, M=100M=100 — 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.

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 D=2D = 2) and recombines them, creating a chain of NN binary diamonds:

The diagram shows N=3N = 3. In the benchmark, NN ranges from 22 to 2020.

  • KK (source updates): 100100.
  • NN (cascade depth): 2,4,6,8,10,12,14,16,18,202, 4, 6, 8, 10, 12, 14, 16, 18, 20.
  • Measurements exceeding 5,000 ms are aborted.
LibraryN=2N=4N=6N=8N=10N=12N=14N=16N=18N=20
SynState0.4 ms0.4 ms0.6 ms0.6 ms0.6 ms0.7 ms0.5 ms0.3 ms0.3 ms0.4 ms
RxJS0.5 ms1.0 ms4.2 ms25.6 ms131.9 ms745.5 ms> 5000 ms> 5000 ms> 5000 ms> 5000 ms
Jotai3.7 ms5.5 ms6.3 ms8.1 ms12.8 ms10.0 ms11.9 ms13.4 ms15.0 ms20.9 ms
MobX0.8 ms1.1 ms1.2 ms1.0 ms0.8 ms0.8 ms1.0 ms1.1 ms1.6 ms1.3 ms
Redux1.6 ms1.4 ms1.9 ms3.0 ms1.8 ms3.1 ms3.6 ms2.8 ms3.1 ms4.8 ms
Solid0.3 ms0.5 ms1.2 ms2.0 ms1.1 ms0.8 ms1.0 ms1.1 ms1.2 ms1.4 ms
Preact Signals0.2 ms0.3 ms0.4 ms0.4 ms0.4 ms0.5 ms0.2 ms0.3 ms0.3 ms0.3 ms
  • RxJS exhibits classic O(2N)O(2^N) exponential growth (binary fan-out). It times out at N=14N=14 (214=16,3842^{14} = 16{,}384 emissions per source update ×100\times 100 updates). At N=12N=12, it already takes 746 ms — 1,096× slower than SynState (0.7 ms).
  • MobX is still only 1.3 ms at N=20N=20, thanks to its lazy computed evaluation. MobX’s computed values are not eagerly re-evaluated when their dependencies change — they are only recomputed when read. The reaction reads only the final computed, which triggers a single lazy pass through the chain. Each computed in the chain is evaluated exactly once per update, yielding O(N)O(N) 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 of reactions (push-based), not computeds (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 NN and 0.3 ms over the three largest — although each runBenchmark call also builds the graph. An update visits each of the 3N+13N+1 observables once, in depth order, and calls the subscriber once. A new observable calls addChild on its direct parents and takes its depth as one more than the deepest of them, so building the graph never walks the 2N2^N 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 NN and 15.0 ms over the three largest. An update recomputes each of the 3N3N 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 O(N)O(N) term to show. At N=20N=20 it is ~55× slower than SynState.
  • Solid and Preact Signals stay at or below 2.0 ms at every NN — a median of 0.5 ms over the three smallest NN 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 3N3N derived nodes once per update: Solid by marking every node below the write before it reruns any, Preact Signals by recomputing a computed only 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 NN and 3.1 ms over the three largest; over NN, the median of its time against SynState’s is 5.4×. Its createSelector chain is pulled by the subscriber, as MobX’s computed is, and each selector, memoized on its inputs, recomputes once per update: 3N3N selector calls, each of which first compares its arguments with the previous ones.

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 00 throughout the benchmark, so only branch[0] is “active”. The benchmark updates branch[1] (an inactive branch) K=105K = 10^5 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, so branch[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) or combineLatest (RxJS) over the selector and every branch subscribes to all branches at construction time, so branch[1] has an observer. When branch[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. When selector = 0, only branch[0].get() is called, so branch[1]’s observer list is empty. Updating branch[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 branches array, so part of the cost grows with B.
LibraryB=2B=5B=10B=20B=50B=100B=200B=500B=1000
SynState (switchMap)2.1 ms2.0 ms1.6 ms1.7 ms1.7 ms1.7 ms1.8 ms5.9 ms2.1 ms
SynState (naive derive)11.4 ms12.1 ms14.9 ms20.3 ms37.9 ms68.4 ms131.6 ms310.2 ms638.6 ms
RxJS (switchMap)3.4 ms2.9 ms2.8 ms2.9 ms2.8 ms2.9 ms3.0 ms3.1 ms3.2 ms
RxJS (naive combineLatest)8.5 ms8.3 ms8.7 ms9.1 ms10.0 ms10.8 ms17.2 ms30.2 ms57.8 ms
Jotai137.7 ms138.6 ms138.7 ms137.3 ms137.5 ms137.5 ms136.1 ms138.6 ms138.2 ms
MobX1.5 ms1.5 ms1.4 ms1.4 ms1.4 ms1.4 ms1.4 ms1.5 ms1.5 ms
Redux136.2 ms133.8 ms134.8 ms118.7 ms125.1 ms130.8 ms133.5 ms150.8 ms180.8 ms
Solid1.0 ms0.7 ms0.7 ms0.7 ms0.7 ms0.7 ms0.7 ms0.7 ms0.8 ms
Preact Signals1.1 ms1.0 ms1.1 ms1.0 ms0.9 ms1.1 ms1.1 ms1.1 ms1.3 ms

The dashed lines are the naive forms. SynState’s own line is the solid “SynState (switchMap)”.

  • MobX, Solid and Preact Signals follow what is read. computed(() => branches[selector.get()].get()) subscribes only to selector and branches[0] at runtime, so branches[1]’s observer list is empty. Updating branches[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 switchMap stays at ~1.8 ms regardless of B, and RxJS with switchMap at ~2.9 ms: an update of branch[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 derive subscribes 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 K=100,000K = 100{,}000 updates). What this row measures is the derive over 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, because combineLatest does not perform equality comparison — it always emits, and the subscriber does minimal work (assigning a value).

  • Jotai uses dynamic subscriptions like MobX — resultAtom does 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 branches array.

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;
};
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;
};
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;
};
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;
};
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;
};

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 K=100,000K = 100{,}000 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 map compares its result with Object.is, and RxJS’s distinctUntilChanged compares the value it passes on. An equal bucket emits nothing, so no view is reached. RxJS compares only where distinctUntilChanged is 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 — before bucket is recomputed. Each view is then checked, finds bucket unchanged 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 useSelector is. Each subscriber’s selector finds bucket unchanged and returns its cached result.
LibraryW=1W=2W=5W=10W=20W=50W=100W=200W=500W=1000
SynState9.1 ms9.5 ms9.2 ms8.7 ms8.8 ms9.2 ms9.4 ms11.4 ms12.4 ms16.3 ms
RxJS7.4 ms7.5 ms7.1 ms7.3 ms7.2 ms7.4 ms7.7 ms8.6 ms10.8 ms15.6 ms
RxJS (naive, no distinctUntilChanged)17.6 ms20.0 ms30.1 ms48.3 ms92.3 ms185.2 ms341.4 ms688.0 ms1848.1 ms3896.0 ms
Jotai447.4 ms492.6 ms562.1 ms703.0 ms993.5 ms1963.5 ms3710.2 ms> 5000 ms> 5000 ms> 5000 ms
MobX66.3 ms78.9 ms114.5 ms173.8 ms287.4 ms638.5 ms1154.6 ms2243.6 ms> 5000 ms> 5000 ms
Redux282.3 ms358.6 ms574.5 ms920.6 ms1777.9 ms4093.0 ms> 5000 ms> 5000 ms> 5000 ms> 5000 ms
Solid46.0 ms50.6 ms66.8 ms94.4 ms157.5 ms340.2 ms654.2 ms1295.0 ms3469.1 ms> 5000 ms
Preact Signals7.6 ms10.2 ms17.9 ms31.1 ms58.0 ms139.3 ms282.0 ms584.8 ms1585.9 ms3345.8 ms

The dashed line is RxJS written without distinctUntilChanged. A row that stops before W=1000 timed out at its next point.

  • 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 bucket passes nothing on, so the views run only on the updates where bucket changes.

  • Preact Signals, Solid, MobX, Jotai and Redux grow with W, because every update reaches every view, whether bucket changed 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 distinctUntilChanged grows 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.

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);
};
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);
};
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);
};
  • Warmup: 5 rounds (discarded).
  • Measured: 20 rounds.
  • NN: 10510^5 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 reads import.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.
  • 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 a createSlice reducer. An Immer draft of MM points or BB branches would be created and finalized element by element on every update, and that would measure Immer rather than the store. The other scenarios keep createSlice, where the state is one number.
  • Redux (Deep Chain): a selector cannot read its own previous output, so the scan chain 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 derive is 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 argument true to subscribe makes 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): combineLatest produces a tuple that a following map turns into the value, so each merge is two nodes where SynState’s derive, MobX’s computed and 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 computed cannot read its own previous output, so the scan chain is written as observable.boxes that one reaction updates 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): combineLatest fires 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 of solid-js resolves to its server build under Node’s node condition, 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 from solid-js. Every graph is created inside createRoot, which is what lets it be disposed, and the subscriber is a createEffect. 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 scan stages 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 an effect; signal.subscribe(fn), which passes the value, is an effect underneath.
  • Preact Signals (Deep Chain): a computed is not given its previous value, so the scan chain is signals that one effect advances inside a batch, reading each stage with peek, as MobX’s reaction does. A computed that 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: reaction is used instead of autorun to match other libraries’ subscribe-and-record pattern. runInAction wraps each mutation as required by MobX strict mode.
  • Jotai: store.sub() does not pass the current value to the callback, so an additional store.get() call is made inside the listener. The initial value is also read via store.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): bucket is shared with shareReplay({ 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 a map of its own, and with share() a view subscribed after the first would not see the current value.
  • Redux (Equality Cut-Off): one store.subscribe per view, as each useSelector subscribes, each reading its view through a createSelector over one shared bucket selector.
  • Conditional Fan-Out: SynState and RxJS are measured twice, naively with a static derive / combineLatest over every branch (the “(naive …)” rows) and with switchMap, which subscribes to the selected branch only, as MobX, Jotai, Solid and Preact Signals do.

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 PP paths reach emits PP times per source update, and everything below it runs each time. That is the O(M2)O(M^2) of the deep chain and the O(2N)O(2^N) 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 M+1M+1 of them runs again on every update and re-tracks M+1M+1 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 NN grows, typically 2.2× its time (the median over NN). 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 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.

When store.set(counterAtom, i) is called, Jotai’s store executes these steps:

  1. Value update — write the new value and compare with Object.is. O(1)O(1).
  2. Invalidation pass (invalidateDependents) — DFS traversal of all mounted dependents, marking each as “needs recomputation” via epoch number tracking in a Map<Atom, EpochNumber>. O(N)O(N) where NN = number of affected atoms.
  3. Topological sort (recomputeInvalidatedAtoms) — a second DFS post-order traversal using WeakSet for visit tracking, producing a correctly ordered list of atoms to recompute. O(N)O(N).
  4. Recomputation — for each atom in topological order, execute its read function. Inside the read function, each get(dep) call: (a) looks up the dependency’s AtomState, (b) compares epoch numbers to check staleness, (c) records the dependency in a Map for future invalidation. The cost is O(∑Di)O(\sum D_i) — the sum of direct dependencies across all recomputed atoms, not the product. In a chain of N atoms each with 1 dependency, this is O(N)O(N).
  5. Subscriber notification — fire listener callbacks. Jotai’s store.sub() does not pass the new value, so the callback must call store.get() again, triggering an additional cache lookup. O(1)O(1) per listener, but adds one extra traversal.
StepSynStateJotai
Graph resolutionDepths fixed at construction; an update queues by depthEvery store.set() (steps 2–3)
PropagationDirect function calls, children queued by the node that emitsread function execution with getter spy + Map/WeakSet operations
Dependency trackingStatic (set at construction time)Dynamic (rediscovered on every evaluation via getter spy)
Staleness checkNot needed (push-based)Epoch number comparison per get() call
Subscriber value deliveryValue passed directly to callbackCallback 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) → call doubled’s map function → call quadrupled’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) → recompute doubledAtom (Map lookup + epoch check + Map write) → recompute quadrupledAtom (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:

  1. Action — the action creator allocates { type, payload }, and dispatch checks that it is a plain object with a string type.
  2. Reducer — the root reducer runs for every action. A createSlice reducer runs inside Immer’s produce: 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.
  3. Notification — every subscriber is called, whatever the action changed. The store keeps no record of which subscriber reads what.
  4. Selectors — each subscriber reads the state back through its selectors. A createSelector selector first looks its argument up in a cache of its own (weakMapMemoize by 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 NN.

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.

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×.