JavaScript Signals: What a Reactive Runtime Actually Needs

The API of signals has converged. The insides have not. This is what a runtime owes you once you write the guarantees down first and build only what they force.

Solid, Preact Signals, Angular, Vue and the TC39 proposal all offer the same three things: a writable signal, a derived computed, a side-effecting effect. What differs is everything you cannot see from the API: what counts as a change, what a failed computation leaves behind, how deep a chain may go, whether a reordered read allocates. Most runtimes answer those questions in their implementation. Reflex Quant answers them in a contract, then derives the implementation from it.

1. The guarantees

The contract has eighteen semantic invariants in four groups, three recorded decisions, one precondition and eight structural bounds. The ones you meet first:

GuaranteeMeaning
Exact tracking (T1)A computation depends on exactly the sources its last run read. Branches add and drop relations.
Uniqueness (T2)One relation per (source, consumer) pair, under repeats, reorders, nesting and failures.
Change is a version (P1)A change is a committed value that differs by Object.is. A → B → A is two changes, not none.
Cutoff (P2)A computed that recomputes to the same value does not propagate.
Glitch freedom (P3)No consumer sees a mix of old and new values; one execution per node per wave.
In-pass invalidation (P5)If a source changes after a computation has read it, the computation owes another execution.
Failure (F1, F4)A computed that throws commits nothing and runs again on its next demand. The obligation to run survives the failure.

The structural bounds are as binding as the semantics: no allocation and no lookup on a stable read, no quadratic tracking, graph size proportional to live relations, traversal that is bounded and stack-safe.

2. Push dirty, pull values

A write cannot recompute everything downstream: that wastes work on branches nobody reads, and in a diamond it shows a consumer one new input and one old. A read cannot re-run everything upstream either. So a write pushes invalidation and a read pulls values.

The push knows less than it seems. For a direct subscriber of the written signal it knows "must run". For everything further down it knows only "may have changed", because a computed in between might recompute to the same value. Those are two different facts, and Quant keeps them as two states: DIRTY and STALE. A read of a STALE node validates its sources in the order it read them and runs only if one really changed. The first changed source is enough to decide. Cutoff is not an extra feature: it is what happens when validation finds nothing.

3. A change is a version, not a flag

Many runtimes record "this changed" as a bit and clear the bit when they have dealt with it. Quant puts a version counter on every node and remembers, on every relation, which version the consumer saw. Two things follow.

A write that lands while a computation is running is classified for free: a source written before it is read is simply read at the new version; a source written after it was read no longer matches the version on the relation, so the run is owed again. And a failure cannot erase a change, because the evidence is not a bit that a failure path might clear. An unconsumed change stays visible until a successful read consumes it.

4. Dynamic dependencies are the hard part

With conditional reads the dependency set is data. A naive runtime clears every relation and rebuilds it on each run, which allocates on every execution. Quant keeps the previous run's relations in read order and checks each read against the next expected one: on a stable trace that is one comparison and two stores, no lookup, no allocation. When the order changes, relations are moved, not rebuilt. How a miss is resolved, cheapest proof first: dynamic dependency tracking.

5. Failure is part of the semantics

What does a computed depend on after it threw halfway through? Quant's answer is exact. Nothing commits. Relations created during the failed attempt are tentative: they can wake the node, but they are not treated as evidence of what it depends on. Relations from the last success that the failed attempt did not reach are kept, because a write to one of them must still wake it. Only a successful run prunes. A failed read is not an observation at all.

6. No recursion

A recursive propagate or validate ties graph depth to the JavaScript call stack: the recursive version of this kernel overflows on a 20,000-deep chain. Quant runs the push, the pull and the tracking bookkeeping on one explicit work stack that stores existing edges, so depth is bounded by memory. The same stack shrinks back when it is empty, so one deep walk does not hold memory forever.

7. Effects are a host concern

The graph machine does not schedule. It decides what reactive evidence exists and tells the host when a watcher becomes eligible. The host owns the queue, the drain loop, batching and the order of a watcher's own cleanup. That boundary is deliberate: ownership of groups of effects, component lifetimes, render scheduling and async resources are framework policy, and they stay out of the runtime until a counterexample shows the contract breaks without them.

The host has a bound of its own: its queue holds storage proportional to the watchers queued at once, not to the deliveries ever made.

8. How to know it is correct

A runtime this small is only interesting if it is right. Quant runs differentially against an executable specification on 27,061 bounded programs, 18,660 recovery programs and 100,000 generated ones, checking graph integrity after every operation. Then each mechanism was removed in turn. All 34 ablations fail a test: 25 a semantic test, 8 a structural bound or representation test, 1 a test of sibling order, which is a convention.

That is a claim about this implementation, and the project says so: an ablation shows that this runtime, minus this mechanism, breaks a named obligation. It does not show that every correct runtime must contain the same mechanism. The research report keeps the two claims apart.

9. What it costs

The suite is this project's own; lower is better. Node 22.23.4, 9 rounds; each ratio measured within one runner; Reflex bf71877, alien-signals 3.2.1. Earlier snapshots: REPORT.md (Q6), HISTORY.md.

Try it

npm install reflex-quant
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

How each guarantee is implemented, with diagrams and runnable counterexamples: anatomy of a reactive engine. What the same machine can express beyond derived values: Game of Life, Fireflies, Constraints.

Evidence

FAQ

What are signals in JavaScript?

Signals are reactive values: reading one inside a computed or effect records a dependency, and writing one re-runs exactly the computations that depend on it.

What is glitch freedom?

A guarantee that no computation observes a mix of old and new values during one update, and that each node executes at most once per wave. A write made during a run is a new obligation and may run a node again.

Why do signal libraries use push-pull?

Pushing invalidation is cheap and lets reads stay lazy; pulling values on read avoids recomputing unread branches and enables cutoff.