Skip to main content

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, notify confirms 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/updateupd/sre-renders/update
MobX (deep/in-place)33.030,3001
MobX (ids-based)33.330,0601
Oimdb (no cnstra)33.429,9401
Cnstra + Oimdb (in-place)33.929,4701
Cnstra + Oimdb (ids-based)34.229,2101
Effector (atomic stores)36.127,7001
— tier boundary —
Effector (ids-based)1,2308131
Zustand (ids-based)2,3724201
Redux Toolkit (ids-based)5,4301841

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+flush0.25
oimdb merge upsert+flush0.34
cnstra → oimdb (full stimulate)0.48
mobx deep in-place + reaction0.67
mobx map.set + reaction0.74
effector atomic + watch0.89
zustand setState + N selectors95
effector record + useStoreMap248
redux dispatch + N selectors302

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.

Adapterheap 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) + useSyncExternalStore hooks. "Like everyone else."
  • Cnstra + Oimdb (in-place)createInPlaceEntityUpdater (mutate the object in place, no allocation) + *Signal hooks (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, useObserver per 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: Record stores + useStoreMap, incremental indexes. Coarse (copies the Record per update).
  • Effector (atomic stores) — a store + event per entity (maximally granular). Fast tier.
  • Zustand — single store, manual normalization, per-component shallow selectors. Coarse.
  • Redux ToolkitcreateEntityAdapter + 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), flushSync per 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 = 200000 across 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.