Benchmark
The hard case is thousands of machines reacting to thousands of events inside one frame budget: trading terminals, live order books, monitoring walls, dense collaborative canvases. That’s what this engine is built for.
▶ Watch it live: all three engines driving a real grid in your browser. Watch which ones fall behind as the load climbs.
The trade-off
Section titled “The trade-off”The one decision behind every number here: Dunky mutates context in place. A transition writes the field and stops, it means no new snapshot, no immutable copy, nothing allocated. That’s what makes the hot path fast and the memory flat (the engine holds one context object per machine for its whole life, no matter how many events hit it). Immutable machines takes the opposite stance, a fresh snapshot per transition, which is why it allocates on every event.
How these numbers were measured
Section titled “How these numbers were measured”These were run on a MacBook Pro (M1, 32 GB). Your absolute numbers will differ, so run it yourself and see.
Why a column is sometimes blank. The three engines don’t run the same way, so not every test fits all three. The cell is marked, the same way everywhere:
n/a ᵃ: async. Zag’ssendis microtask-batched, so it can’t run in a synchronous ops/sec orflushSyncloop, only where it runs synchronously (construction, memory, React rendering).n/a ᶠ: no equivalent feature. The engine has no first-class primitive for that scenario (e.g. XState has no lazy/memoizedcomputed), so there’s nothing comparable to time.
Reading the ops/sec tables: higher is better, and a gap between two rows is only real if it clears both rows’ run-to-run noise (±rme). A 1.0× / 1.2× difference is a tie; the 8×–33× gaps are the point.
Overview
Section titled “Overview”| Dunky | XState | Zag | |
|---|---|---|---|
| Event throughput | 7.2 M ops/s | 897 K | n/a ᵃ |
| Memory, 2-field context | 3.6 KB | 3.6 KB | 9.1 KB |
| Memory, 64-field context | 4.1 KB | 4.1 KB | 134 KB |
| Re-render wall, 1000 rows | 3.9 ms | 6.8 ms | n/a ᵃ |
ᵃ Zag’s send is microtask-batched; can’t run in a synchronous ops/sec loop.
Event throughput
Section titled “Event throughput”A single machine, one event, tight loop:
| Dunky | XState | Zag | |
|---|---|---|---|
| ops/sec | 7.2 M | 898 K | n/a ᵃ |
XState allocates a new immutable snapshot on every transition. Dunky mutates context in place, so a transition allocates nothing.
Writes that nobody is watching
Section titled “Writes that nobody is watching”Change a field no observer has selected. The dedup layer re-evaluates and value-compares, and no listener fires:
| Observers | Dunky (ops/s) | XState (ops/s) |
|---|---|---|
| 1 000 | 4.5 M | 536 K |
| 5 000 | 1.9 M | 453 K |
XState’s actor.subscribe is coarse: it fires on every snapshot change. To match Dunky’s behavior you’d add a differ in the listener, which is what the xstate column already does, for a fair comparison.
Selection at scale
Section titled “Selection at scale”One machine with N observers; bump one field and measure how many of these “wake only the one affected observer” cycles complete per second (higher is better):
| Observers | Dunky (ops/s) | XState (ops/s) |
|---|---|---|
| 100 | 325 K | 253 K |
| 1 000 | 10.7 K | 10.7 K |
| 5 000 | 7.9 K | 741 |
Roughly par at small N. The gap widens with N because a coarse subscribe re-runs every listener on each change, while a fine-grained selection wakes only the affected one.
Memory as context grows wide
Section titled “Memory as context grows wide”The whole point of the plain-object model: memory grows with your data, not with the framework’s bookkeeping.
| Context width | Dunky | XState | Zag |
|---|---|---|---|
| 2 fields | 3.6 KB | 3.6 KB | 9.1 KB |
| 64 fields | 4.1 KB | 4.1 KB | 134 KB |
Going 2 → 64 fields costs Dunky ~0.5 KB/machine. Zag allocates one reactive cell per field, so a 64-field context grows to 134 KB/machine.
Construction cost
Section titled “Construction cost”Spin-up cost per machine:
| Dunky | XState | Zag | |
|---|---|---|---|
| µs / machine | 2.42 | 1.95 | 8.16 |
XState cold-starts ~1.2× faster; Zag is ~3.4× slower than both. A one-time cost paid at start(); see the trade-off.
Where this matters
Section titled “Where this matters”These loads are extreme by design: they only reflect real software when a single view holds thousands of independently-stateful, live-updating cells in one frame budget:
| Workload | Live cells |
|---|---|
| Full L2 order book (Bookmap, Sierra Chart) | 3k – 15k |
| Options chain / vol surface (thinkorswim, Tastytrade) | 5k – 20k |
| Screeners / heatmaps (TradingView, Finviz) | 3k – 15k |
| Live-data spreadsheets (Excel + market feeds) | 5k – 20k |
| Observability walls (Grafana, Datadog) | 5k – 50k |
| Dense collaborative canvases (Figma, tldraw) | 5k – 50k |
The sharpest target is a dense live financial surface: 5k–20k live cells, updating tens of thousands of times per second, often needed on web and native from one codebase, where every property this benchmark measures pays at once.
Full methodology, fairness notes, and all per-scenario tables in the benchmark README.