Skip to main content

Performance

Benchmarks

Numbers on an average laptop — treat as directional.

npx tsx packages/core/bench/index.ts

Collection

Operation1K entities100K entities1M entities
Insert6.2M/s2.1M/s600K/s
Update2.7M/s1.8M/s800K/s
Delete5.8M/s2.3M/s700K/s
Lookup9.8M/s9.5M/s9.2M/s

Schedulers

SchedulerThroughputBest for
createMicrotask()2.4B/sDefault — same-tick delivery
createImmediate()1.8B/sTests
createTimeout(0)16M/sBatch / import workloads
createAnimationFrame()8M/sUI-aligned, React-friendly

Indexes

Operation1K keys100K keys
Set / Add / Remove6–8M/s1–2M/s
Lookup9.1M/s8.9M/s

Computed values and effects run inside the same queue.flush() drain — derived recompute and subscriber delivery happen in the same pass.

Compared to other state managers

Cross-library numbers measured in three planes, because they answer different questions. These are directional — reproduce on your own hardware/versions before quoting.

Run it and read every adapter yourself — nothing here is hand-waved:

Methodology that matters: production React build only (dev adds ~2× jsxDEV/validation overhead and distorts everything), one update at a time via flushSync (so this is CPU cost per update, not frame-paced perceived latency), instrumentation kept out of the timing window.

React throughput (production)

Cost of one entity update in a real React app (1500 components, 1 subscriber each). Lower is better.

Library (idiom)µs/updatere-renders/update
MobX (deep, in-place)33.01
MobX (ids-based)33.31
oimdb33.41
oimdb + cnstra (in-place)33.91
oimdb + cnstra (ids-based)34.21
Effector (atomic stores)36.11
— tier boundary —
Effector (ids-based, Record copy)1,2301
Zustand (ids-based)2,3721
Redux Toolkit (ids-based)5,4301

Read this honestly. Inside the top tier the 33–36µs spread is noise: React's commit cost dominates and swamps the store layer, so a 1–3µs lead is a coin flip that does not reproduce. What React does not hide is fine-grained vs coarse — stores that copy the whole collection and re-run every selector on each update (effector-ids, zustand, redux) land 35–160× slower. oimdb sits firmly in the fine-grained tier: on par with the best (MobX deep, atomic Effector), without observer everywhere or a store-per-entity. The re-renders /update = 1 column confirms all fine-grained libraries invalidate equally precisely — the difference is pure per-update CPU, not render volume.

Data layer (no React)

Pure cost of a store update plus confirmed delivery to one subscriber per entity — i.e. the end-to-end cost when there is no React commit to hide behind. Lower is better.

Layerµs/update
oimdb in-place upsert + flush0.25
oimdb merge upsert + flush0.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

Here the fine-grained stores rank honestly and oimdb leads (~2–3× MobX). Keep the proportion: in a React app this ~0.4µs lives under a ~33µs commit, so it is invisible end-to-end — the top table rules out any "faster in your React app" claim. It becomes visible exactly where the bottleneck is the data layer and not the React commit:

  • headless / non-render consumers — computed/effect graphs, persistence, server-side, data pipelines, game loops;
  • cnstra-orchestrated flows (the 0.48µs full stimulate above is already end-to-end — no React);
  • high-frequency updates where thousands of entities change but only a few are on screen — the store cost is paid for all, the render only for the visible;
  • fine-grained renderers (Solid/Svelte) whose per-update floor is low enough for the data-layer ranking to show through (lower floor → the 0.4µs is a larger share). This last case is plausible but not yet measured here — treat it as a hypothesis until there is a Solid/Svelte adapter on the same workload.

Memory (steady-state heap, production)

Heap after GC with the same rendered DOM across all adapters (50,162 nodes). Lower is better.

Library (idiom)heap MB
oimdb + cnstra (in-place)25.8
oimdb + cnstra (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

This is where the fast-tier trade-offs become explicit:

  • oimdb/cnstra has the lightest footprint (25.8–28.1 MB); in-place mode trims it further by avoiding per-update allocation — the steady-state heap backs the allocation argument that a mean-µs number alone can't show.
  • Atomic Effector is in the fast update tier (36µs) but costs 89.7 MB, ~3.5× the lightest — a store + event per entity means thousands of units. It buys update speed with memory; name that trade-off.
  • MobX deep (37.4) is heavier than MobX ids (31.5): deep observables wrap every field in a proxy/atom. The "native" mode has its own memory cost.

Caveat: a single steady-state heap number doesn't capture GC pause distribution — in-place's allocation savings mainly help tail latency under sustained high-frequency updates, which this number only hints at.

Bottom line: oimdb is in the fast tier for React, the fastest measured at the data layer, and the lightest in memory — with a real end-to-end win wherever the data layer (not the React commit) is the bottleneck. The honest non-claim: it is not faster than MobX/atomic-Effector inside a React app — React's commit floor erases that — but it reaches the same tier without their memory cost (atomic Effector) or observer/proxy overhead (MobX deep).

Mutable mode (advanced)

By default entities are updated immutably ({ ...prev, ...draft }), so each update produces a new object reference — required for React's Object.is change detection (useSyncExternalStore). The copy is the largest per-update data-layer cost.

For update-heavy, fine-grained UIs you can update entities in place and bind with the lighter signal hooks:

import { OIMReactiveCollection, createInPlaceEntityUpdater } from '@oimdb/core';
import { useSelectEntityByPkSignal } from '@oimdb/react';

const cards = new OIMReactiveCollection(queue, {
selectPk: c => c.id,
updateEntity: createInPlaceEntityUpdater(), // mutate in place — no per-update allocation
});

// Signal hooks re-render on the keyed notification (no Object.is), so they see
// in-place mutations that the default uSES hooks would miss.
const card = useSelectEntityByPkSignal(cards, id);

This drops both the merge copy and the useSyncExternalStore overhead — the per-update work MobX also avoids. It pays off where the data layer is the bottleneck (very fine-grained renderers, large update-heavy lists); in plain React the per-component commit dominates, so the win is small.

Use only when every reader is subscription-driven. Trade-offs: not Concurrent-Mode safe; the entity reference is stable across changes, so React.memo on entities, prev/next diffing, time-travel, and the default uSES hooks on the same collection won't see updates. Select each entity where you render it (by pk); don't pass mutable entities into React.memo children.

Index membership writes

addPks / removePks are incremental (touch only the changed pks). For frequently changing membership prefer a set-based index — both add and remove are O(1) per pk; array-based add is O(1) but removal is O(bucket) (it preserves order). See Indexes — set vs array.

Key patterns

Batch writes

// ✅ single notification batch, slots reusable by indexes
const slots = users.upsertMany(largeUserArray);
rawIndex.setSlots('visible', slots);

// ❌ N pending notifications before flush
largeUserArray.forEach(user => users.upsertOne(user));

Scheduler for bulk imports

microtask (default) is fastest for typical UI writes. For large bulk imports, use timeout to avoid blocking the event loop:

const importQueue = new OIMEventQueue({
scheduler: OIMEventQueueSchedulerFactory.createTimeout(0),
});

Index comparators

Choosing the wrong comparator triggers unnecessary re-renders.

// Order-sensitive (search results, ranked lists)
OIMIndexComparatorFactory.createElementWiseComparator<string>()

// Order-insensitive (tags, roles) — ignores reordering
OIMIndexComparatorFactory.createSetBasedComparator<string>()

// Always notify (external ordering you don't control)
OIMIndexComparatorFactory.createAlwaysUpdateComparator<string>()

Slot-backed reads

Indexes store slot references. Entity-by-index reads use slot.item directly with no secondary Map lookup per PK. Prefer entitiesBySetIndexKey / entitiesByArrayIndexKey selectors over getPksByKey + manual lookup.

Cleanup

const off = users.updateEventEmitter.subscribeOnKey('u1', handler);
off(); // unsubscribe when done

index.destroy(); // release indexes you no longer need