Anatomy of a Reactive Engine: How Reflex Quant Is Built

Every reactive library starts simple. Then dependencies change at runtime, computations throw, effects dispose themselves, writes happen mid-run, and graphs grow to thousands of nodes. Each case tempts one more flag, one more index, one more queue.

Reflex Quant asks the opposite question: fix the semantics first, then keep only the machinery they force. This is what was left.

~2.2 KBgzip, whole runtime
10public operations
2object types: Node, Edge
0call-stack recursion

Three layers, one owner per fact

Kernel

What depends on what, what changed, what must re-check. The only source of truth about dependencies.

Host

Delivers work to effects: queue, batching, cleanup order. Never decides whether a dependency exists.

Surface

The typed API. A handle is the node, with a type brand: no wrapper object.

A UI framework can replace the host (its own phases, ownership, async) without touching the graph.

Two objects. One edge lives in two lists.

interface Node { flags; value; ver; fn; deps; subs; lastSub; slot; gen }  // 9 fields
interface Edge { from; to; ver; gen; prev; next; prevSub; nextSub }     // 8 fields
X.depsA → XB → XC → Xprev / next: read orderB.subsB → WB → YprevSub / nextSub: delivery order
The consumer's list drives tracking and validation; the source's list drives invalidation. Same object, so nothing to synchronize.

Each edge also carries ver (which version of the source the consumer saw) and gen (whether a successful run confirmed it). Facts that other runtimes keep in separate tables (read history, dirty sets, rollback logs) live on the relation itself.

Exact tracking: depend on what you read, not what you wrote

fullfirstlastnameeffect
Only full and first are edges. Writing last wakes nothing.The branch flips: last becomes an edge. Flip back and it is dropped again.
import { computed, effect, get, set, signal } from "reflex-quant";

const full = signal(false);
const first = signal("Ada");
const last = signal("Lovelace");
const name = computed(() => (get(full) ? `${get(first)} ${get(last)}` : get(first)));

effect(() => console.log(get(name))); // "Ada"        name reads full, first
set(last, "Byron");                   // nothing runs: last was not read
set(full, true);                      // "Ada Byron"  name now reads last too
set(full, false);                     // "Ada"        last is dropped again
set(last, "King");                    // nothing runs
runs reflex-quant 1.1.2 in your browser

Harder case: the same sources, read in a different order.

0 / 0Reflex Quant: edges created / removed per pass
255 / 255alien-signals 3.2.1: relinks per pass

Structure counter reorder.rotate-256: 256 sources rotated every pass. Quant moves existing edges; it does not rebuild them. More on dynamic tracking.

How a read finds its edge, cheapest proof first

  1. Cursor hit. The next edge in the list is this source: advance.O(1), no lookup
  2. Repeat. Same source as the last read.O(1)
  3. Slot. This pass already indexed the sources: source.slot points at the edge.O(1)
  4. No subscribers. The source has none, so the edge cannot exist: create it.O(1)
  5. Bounded scan of SCAN = 8 edges: found, proven absent, or unknown.≤ 8 steps
  6. Index once, then every later miss in the pass is a slot lookup.one linear pass

"Not found in 8" never means "absent". The scan is a price limit, not a precision limit, so tracking never turns quadratic when hundreds of reads are shuffled.

State proven by structure, not stored

// "this pass is indexed": the consumer owns its cursor's slot
cursor !== null && cursor.from.slot === cursor
// "this pass is marked": unread edges form one contiguous suffix
next !== null && next.ver === UNREAD

In 1.1.2 two mode flags were deleted: the invariants on edge order and slot ownership already prove them.

Push dirty, pull values

A set()B DIRTYC DIRTYD STALEeffect E STALE
DIRTY: a source surely changed, run it. STALE: something upstream might have changed, check versions first. D runs once, after B and C: no glitch.

A write only marks. A read (or an effect's turn) pulls: validate sources in read order, stop at the first changed one, run. If the new result is Object.is-equal to the old one, its version does not move and everything below stays clean: cutoff.

import { computed, effect, get, set, signal } from "reflex-quant";

const n = signal(1);
const parity = computed(() => (get(n) % 2 === 0 ? "even" : "odd"));
effect(() => console.log("effect:", get(parity)));

set(n, 3); // parity recomputes to "odd" again: cutoff, the effect stays quiet
set(n, 4); // "even": the effect runs
runs reflex-quant 1.1.2 in your browser

Versions, not values

StepA.valueA.veredge A→B .ver
B reads A11010
set(A, 2)21110
set(A, 1)11210 ≠ 12 → re-run

The value came back (A→B→A), but the change is not lost. One counter gives both properties: no lost changes, and cutoff for unchanged results.

import { computed, get, set, signal } from "reflex-quant";

const A = signal(1);
let runs = 0;
const B = computed(() => (runs++, get(A) * 10));

get(B);
set(A, 2);
set(A, 1); // back to 1, but the version moved twice
console.log(get(B), "runs:", runs); // 10 runs: 2
runs reflex-quant 1.1.2 in your browser

Effects check more than computeds

A computed may run as soon as one source changed. An effect first validates all its committed sources, because its next step, running the old cleanup, is irreversible:

  1. validate every committed source (a throw stops here, cleanup intact);
  2. still DIRTY? if no result changed, nothing runs;
  3. run the previous cleanup, untracked;
  4. run the effect; its return value is the next cleanup.

A write inside a run is not forgotten

import { computed, get, set, signal } from "reflex-quant";

const a = signal(1);
const c = computed(() => {
  const first = get(a);
  if (first === 1) set(a, 2);
  return first + get(a);
});

console.log(get(c)); // 3
console.log(get(c)); // 4
console.log(get(c)); // 4
runs reflex-quant 1.1.2 in your browser
31st get(c): read 1, then 2
42nd get(c): the debt forces a re-run, a is 2
43rd get(c): settled

The edge keeps the version of the first read, so the change made mid-run leaves a debt of one more run. Rereading a would otherwise erase the evidence.

Failure must not rewrite history

RunReadsOutcomeEdges kept
1A B CokA B C committed
2A DthrowsA B C + D tentative
3A EthrowsA B C + E (D dropped)

B and C belong to the last good run, so they stay. D or E may be what fixes the error, so a change there must still wake the node. No rollback log: an edge created during a run is stamped with the next generation, node.gen + 1. A successful run bumps node.gen once, which commits every new edge at once. When the 30-bit counter nears wrap-around, a rare linear pass renumbers the edges.

import { computed, get, set, signal } from "reflex-quant";

const ready = signal(false);
const c = computed(() => {
  if (!get(ready)) throw new Error("not ready");
  return "ready";
});

try { get(c); } catch (e) { console.log("threw:", e.message); }
set(ready, true); // the failed run's dependency still wakes it
console.log(get(c));
runs reflex-quant 1.1.2 in your browser

One stack, three jobs

Push

Saves the next sibling to come back to after a deep branch.

Pull

Saves the edge to climb back up; on a throw, marks the path for re-run.

Tracking

Saves the slot a nested pass displaced, restored via edge.from.

Watch it on application code: the same snippet, run by Quant and by a naive run-on-read runtime.

Your code
The Quant run is recorded from a model of the kernel and host that reproduces reflex-quant 1.1.2 on every example: the same callbacks, in the same order, with the same output. stack[] holds edges: a sibling the push will come back to, or the way back up a pull. Edges in the graph are the live dependencies, so a branch switch adds and drops them. The naive runtime re-runs whatever is stale when read, nesting closures, and has no cutoff.

All three save an Edge, so one array serves them all, and graph depth is bounded by memory instead of the JavaScript call stack. Storing only the edge (its source is edge.from) halved the stack peak in a nested-index test:

1,542peak stack slots, before
771after, same graph (LOG 38)

batch() is not a transaction

import { batch, effect, get, set, signal } from "reflex-quant";

const a = signal(0);
effect(() => console.log("effect sees", get(a)));

batch(() => {
  set(a, 1);
  set(a, 2);
  console.log("inside batch", get(a)); // 2: writes commit immediately
});                                    // only the effect waited
console.log("after batch");
runs reflex-quant 1.1.2 in your browser

No rollback, no isolation: batching defers effect delivery, nothing else. If one effect throws, the rest of the queue still runs and the first error reaches the caller.

What it costs

A single read or write is not cheaper than in alien-signals. Exact tracking has a price per operation; the payoff is the graph work that never happens.

1.03×geomean vs the better rival
0.99×vs Reflex
0.45×vs alien-signals
7 / 18faster than both
Time of Reflex and alien-signals relative to Reflex Quant, per scenarioQuant is fixed at 1×. Points to the right are slower than Quant. Geometric means: Reflex 1.01×, alien-signals 2.20×.← faster than Quantslower than Quant →reorder.rotate-256: Quant 1×, Reflex 0.83×, alien-signals 6.67× (time relative to Quant)reorder.rotate-2566.67×reorder.reverse-8: Quant 1×, Reflex 0.83×, alien-signals 5.88× (time relative to Quant)reorder.reverse-85.88×long-reorder.256: Quant 1×, Reflex 0.83×, alien-signals 5.88× (time relative to Quant)long-reorder.2565.88×edit.insert-one-256: Quant 1×, Reflex 0.94×, alien-signals 4.76× (time relative to Quant)edit.insert-one-2564.76×shared-chaotic.64x32: Quant 1×, Reflex 1.28×, alien-signals 4.55× (time relative to Quant)shared-chaotic.64x324.55×value-sort.256x32: Quant 1×, Reflex 0.70×, alien-signals 3.33× (time relative to Quant)value-sort.256x323.33×fan-in.1024: Quant 1×, Reflex 1.01×, alien-signals 2.63× (time relative to Quant)fan-in.10242.63×batch.writes-32: Quant 1×, Reflex 1.82×, alien-signals 2.22× (time relative to Quant)batch.writes-322.22×branch.switch-100: Quant 1×, Reflex 1.15×, alien-signals 2.13× (time relative to Quant)branch.switch-1002.13×stable.wide-256: Quant 1×, Reflex 0.85×, alien-signals 2.13× (time relative to Quant)stable.wide-2562.13×stable.wide-8: Quant 1×, Reflex 0.85×, alien-signals 1.72× (time relative to Quant)stable.wide-81.72×diamond.8x8: Quant 1×, Reflex 1.08×, alien-signals 1.33× (time relative to Quant)diamond.8x81.33×replace-half.256: Quant 1×, Reflex 1.10×, alien-signals 1.30× (time relative to Quant)replace-half.2561.30×fan-out.computeds-1000: Quant 1×, Reflex 0.85×, alien-signals 1.16× (time relative to Quant)fan-out.computeds-10001.16×fan-out.effects-1000: Quant 1×, Reflex 1.19×, alien-signals 1.14× (time relative to Quant)fan-out.effects-10001.14×static.chain-100: Quant 1×, Reflex 0.92×, alien-signals 1.08× (time relative to Quant)static.chain-1001.08×lifecycle.mount-100: Quant 1×, Reflex 1.52×, alien-signals 0.96× (time relative to Quant)lifecycle.mount-100alien 0.96×static.read-clean: Quant 1×, Reflex 0.91×, alien-signals 0.82× (time relative to Quant)static.read-cleanalien wins: turns out the fastest graph is no graph at all :)0.5×1×2×4×8×
Time relative to Reflex Quant (Quant = 1×, log scale, lower is faster). Sorted by alien-signals: the more the scenario looks like a graph, with reorders, fan-in and shared nodes, the further it falls behind. Labels show alien-signals. CI run 42794143933, Node 22.23.4, 9 rounds; each ratio measured within one runner.
Table view
ScenarioQuantReflexalien-signals
reorder.rotate-2561.000.836.67
reorder.reverse-81.000.835.88
long-reorder.2561.000.835.88
edit.insert-one-2561.000.944.76
shared-chaotic.64x321.001.284.55
value-sort.256x321.000.703.33
fan-in.10241.001.012.63
batch.writes-321.001.822.22
branch.switch-1001.001.152.13
stable.wide-2561.000.852.13
stable.wide-81.000.851.72
diamond.8x81.001.081.33
replace-half.2561.001.101.30
fan-out.computeds-10001.000.851.16
fan-out.effects-10001.001.191.14
static.chain-1001.000.921.08
lifecycle.mount-1001.001.520.96
static.read-clean1.000.910.82
Geometric mean1.001.012.20

Quant wins on batched writes, chaotic shared graphs and branch switches. It loses on reorders and sorts, where Reflex's specialised algorithms are faster: some of Reflex's machinery does buy speed, at the price of complexity. Full methodology: benchmarks.

How it is checked

27,061bounded programs
18,660recovery programs
100,000generated programs
34mechanism ablations, each fails a test

Each program runs against an executable contract, checking graph integrity after every operation. An ablation shows that this implementation breaks without a mechanism, not that every possible design needs it. "Minimal" means the smallest known set for this contract, not a proven lower bound.

What Quant deliberately leaves out

Effect scopes, ownership trees, a render scheduler, async resources, transactions, automatic cleanup of unwatched computeds. Under the project's rule, a mechanism enters the core only if the contract or the asymptotics break without it. Frameworks can build these on top, because the kernel does not own them.

A runtime gets simpler not by solving fewer problems, but by storing each truth in one place. The price: correctness now rests on a few strong invariants instead of many synchronized fields.

Try it

npm install reflex-quant

Evidence

Read next: what a signals runtime must guarantee, alien-signals vs Reflex Quant. Design notes: cost model, V8 feedback audit.

FAQ

How is a signals library implemented?

As a graph of nodes (signals, computeds, effects) and edges (dependencies). Writes push invalidation down the graph; reads pull fresh values up, re-running a computation only when a source it read has actually changed.

Why not just store dependencies in a Set?

A Set rebuilt on every run allocates on every execution and loses read order. Reusing the previous run's edges, in read order, makes stable and reordered reads allocation-free.

What happens when a computed throws?

In Reflex Quant it commits nothing, keeps the dependencies of its last successful run plus those of the failed attempt, and runs again on the next read.