React State Management Benchmark - Performance Comparison
Overview
We compared Cnstra + OIMDB against MobX, Effector, Zustand, and Redux Toolkit. The honest summary up front:
- Among fine-grained (per-key) stores, React's commit cost dominates and they all tie at ~33–36 µs/update. The 1–3 µs spread between them is not reproducible — it's a coin flip.
- The reproducible difference is fine-grained vs coarse. Coarse stores (copy the whole record + re-run all selectors per update) land at 1230–5430 µs/update — 35–160× slower — and React does not hide this.
- On the pure data layer (no React), OIMDB/Cnstra are the fastest (0.25–0.48 µs/update), ~2–3× faster than MobX, while coarse stores are 95–302 µs.
- On memory, Cnstra/OIMDB have the lightest footprint (25.8–28.1 MB steady-state). Atomic Effector is ~3.5× heavier (89.7 MB) — the price of a store + event per entity.
🔗 Interactive Benchmark Results | 📦 Benchmark Source Code
Three measurement planes (and why)
The three planes answer different questions, so we report all of them:
- React throughput (production build) — the cost of one update in a real React app (
flushSync, no frame floor). Production build only (vite build+preview): a dev build of React adds ~2× overhead (jsxDEV/validation) and distorts everything. - Data-layer micro (no React) — the pure cost of a store update: 1 subscriber per entity,
notifyconfirms delivery. - Steady-state memory — heap after GC in the production app, with an identical DOM (50,162 nodes for every adapter), so the difference is the store layer, not the view.
Table 1 — React throughput, production (µs/update, lower is better)
1500 components, best-of-N.
| Adapter | µs/update | upd/s | re-renders/update |
|---|---|---|---|
| MobX (deep/in-place) | 33.0 | 30,300 | 1 |
| MobX (ids-based) | 33.3 | 30,060 | 1 |
| Oimdb (no cnstra) | 33.4 | 29,940 | 1 |
| Cnstra + Oimdb (in-place) | 33.9 | 29,470 | 1 |
| Cnstra + Oimdb (ids-based) | 34.2 | 29,210 | 1 |
| Effector (atomic stores) | 36.1 | 27,700 | 1 |
| — tier boundary — | |||
| Effector (ids-based) | 1,230 | 813 | 1 |
| Zustand (ids-based) | 2,372 | 420 | 1 |
| Redux Toolkit (ids-based) | 5,430 | 184 | 1 |
The top six (all fine-grained) are within noise of each other. The bottom three (all coarse) are 35–160× slower — that gap is real and reproducible.
Table 2 — Data layer, no React (µs/update)
1 subscriber per entity; notify = 200000 for all.
| Layer | µs/update |
|---|---|
| oimdb in-place upsert+flush | 0.25 |
| oimdb merge upsert+flush | 0.34 |
| cnstra → oimdb (full stimulate) | 0.48 |
| mobx deep in-place + reaction | 0.67 |
| mobx map.set + reaction | 0.74 |
| effector atomic + watch | 0.89 |
| zustand setState + N selectors | 95 |
| effector record + useStoreMap | 248 |
| redux dispatch + N selectors | 302 |
This is the only place where fine-grained stores rank honestly — and OIMDB/Cnstra lead. Note that adding the full Cnstra orchestration on top of OIMDB (0.34 → 0.48 µs) is a small, fixed cost, not a multiplier.
Table 3 — Steady-state heap, production, after GC (MB, lower is better)
Identical DOM across all adapters (50,162 nodes), so this isolates the store layer.
| Adapter | heap MB |
|---|---|
| Cnstra + Oimdb (in-place) | 25.8 |
| Cnstra + Oimdb (ids-based) | 28.1 |
| Oimdb (no cnstra) | 28.1 |
| Zustand (ids-based) | 30.2 |
| MobX (ids-based) | 31.5 |
| MobX (deep/in-place) | 37.4 |
| Redux Toolkit (ids-based) | 37.7 |
| Effector (ids-based) | 42.0 |
| Effector (atomic stores) | 89.7 |
Memory is where the speed trade-offs become visible:
- Cnstra/OIMDB are the lightest (25.8–28.1 MB). The in-place updater also avoids per-update allocations, so it's the leanest of all.
- Atomic Effector — 89.7 MB, ~3.5× heavier than anyone else. That's the cost of a store + event per entity: thousands of Effector units. Atomic Effector buys its fast-tier update speed (Table 1/2) with memory — name this trade-off explicitly before reaching for it.
- MobX deep (37.4 MB) is heavier than MobX ids (31.5 MB) — deep observables wrap every field (proxies/atoms), so the "native" idiom costs more memory than the normalized one.
- Redux / Effector-ids sit at ~38–42 MB.
What the variants are (not duplicates)
Several variants per framework are different data-access idioms, not repeats:
- Cnstra + Oimdb (ids-based) — the default: a merge updater (each upsert creates a new entity object) +
useSyncExternalStorehooks. "Like everyone else." - Cnstra + Oimdb (in-place) —
createInPlaceEntityUpdater(mutate the object in place, no allocation) +*Signalhooks (re-render by key subscription, read the mutated object). Fastest on the data layer. - Oimdb (no cnstra) — pure OIMDB, writing directly to the collection without CNS orchestration. Included to isolate Cnstra's cost (34.2 vs 33.4 → orchestration ≈ noise).
- MobX (ids-based) —
observable.map, replace the whole entity,useObserverper hook (normalized store, "like everyone else"). - MobX (deep/in-place) — idiomatic MobX: deep observables, mutate a field in place, components in
observer()(read observables directly in JSX). MobX's best case. - Effector (ids-based) — idiomatic:
Recordstores +useStoreMap, incremental indexes. Coarse (copies theRecordper update). - Effector (atomic stores) — a store + event per entity (maximally granular). Fast tier.
- Zustand — single store, manual normalization, per-component shallow selectors. Coarse.
- Redux Toolkit —
createEntityAdapter+ memoized selectors (Immer). Coarse.
Findings
1. React noise drowns out good frameworks
In production React, every fine-grained (per-key) store — OIMDB, Cnstra, both MobX variants, atomic Effector — collapses into 33–36 µs. The 1–3 µs spread does not reproduce: paired runs gave a coin flip (OIMDB faster in 4/10, MobX in 6/10, median +1 µs). The cost of the React commit itself dominates and zeroes out the differences between store layers — their React numbers carry no signal.
2. Only two things are distinguishable
- Fine vs coarse. Coarse stores (Effector-ids, Zustand, Redux) copy the entire record and re-run all N selectors per update → 1230 / 2372 / 5430 µs, 35–160× slower. React does not hide this.
- The data layer (no React). There the real store speed shows: OIMDB/Cnstra lead (0.25–0.48 µs), ~2–3× faster than MobX (0.67–0.74), and coarse stores are 95–302 µs. This is the one place fine-grained frameworks rank honestly — and OIMDB is ahead.
3. Re-renders/update = 1 for everyone
All frameworks are equally precise about React invalidation. The difference is purely the CPU cost of the update, not the volume of rendering.
What this means in practice
- If you're already using a fine-grained store (OIMDB/Cnstra, MobX, atomic Effector), don't expect a React-visible speedup from switching between them — React dominates. Choose on ergonomics, architecture, and the data-layer cost (which matters off the render path: workers, sync engines, large in-memory derivations).
- The decision that does move React throughput by orders of magnitude is fine-grained vs coarse. Coarse normalization (copy-the-record + re-run-all-selectors) is what costs 35–160×.
- Cnstra + OIMDB sits in the top tier under React, is the fastest on the data layer, and has the lightest memory footprint — with the orchestration overhead measured as noise.
- Watch the memory trade-off. Granularity isn't free: atomic Effector wins a fast update tier but pays ~3.5× the heap (a unit per entity), and MobX's deep/native idiom costs more memory than its normalized one. Speed, memory, and ergonomics pull in different directions — pick deliberately.
Why coarse stores are slow
Coarse stores (Zustand/Redux/Effector-ids as benchmarked) update a single normalized record (Record<ID, T> or equivalent) and then re-run every subscribing selector to find what changed. Cost scales with the number of subscribers/selectors, not with the number of changed entities. OIMDB instead maintains Map<Key, Set<PK>> indexes incrementally and notifies only the affected keys, so an update touches O(changed) work regardless of how many components are mounted.
Methodology
- React plane: production build (
vite build+preview),flushSyncper update (no frame floor / no batching across updates), 1500 mounted components, best-of-N with warmup. - Data-layer plane: no React; 1 subscriber per entity;
notify = 200000across all adapters to confirm delivery. - Memory plane: steady-state heap measured after GC in the production build, with an identical DOM (50,162 nodes) across all adapters so the delta reflects the store layer, not the view.
- Same machine/environment for all adapters; relative differences (especially the fine-vs-coarse gap and the memory spread) are robust across environments. Sub-3 µs differences inside the fine-grained tier are within noise and should not be read as rankings.