FrameworksReactivityPart 2
Your Reactive Graph Computed 4,11. That State Never Existed.
A single-pass push through a diamond recomputes the leaf twice, and the first result mixes a fresh value with a stale one. Two passes are what remove it.

Here is a reactive graph in the shape everybody draws: one source, two derived values, one leaf that reads both.
const a = signal(1);
const b = computed(() => a.v * 2, [a]);
const c = computed(() => a.v + 10, [a]);
const d = computed(() => `${b.v},${c.v}`, [b, c]);
a.set(2);With a naive implementation, one where a source pushes new values straight to
its dependents, d runs twice:
$ node naive.mjs
d ran 2 time(s): 4,11 | 4,12The second answer is right. The first one, 4,11, is b after the update and
c before it: a pair that no assignment ever produced and no reader should ever
see. That is a glitch, and the launch article on
signals named it without deriving it. The
derivation is the point here, because it explains a design choice every signals
library makes.
One pass cannot avoid it
Follow the push. a.set(2) walks a’s dependents in order. b recomputes to
4, and because b has its own dependent, it immediately pushes to d. At that
moment c has not been visited at all — it is still 11. d recomputes with
what it can see.
Then the walk returns to a’s list, reaches c, recomputes it to 12, and
pushes to d again.
There is no ordering that fixes this, and that is the load-bearing claim.
Visiting c before b produces 2,12 instead of 4,11 — a different wrong
answer. The problem is not the order of the walk; it is that the walk carries
values. A value is a claim that everything it was derived from is current, and
during a traversal that claim is false for every node not yet visited.
Two passes, because a marker claims nothing
The fix is to split the traversal in two and let only the second one carry values. The first pass pushes a marker:
function mark(node) {
for (const d of node.deps) {
if (d.state === STALE) continue; // already marked; do not walk twice
d.state = STALE;
mark(d);
}
}The second pass is a pull, and it happens on read:
c.get = () => { if (c.state === STALE) { c.v = c.fn(); c.state = CLEAN } return c.v };d.get() finds itself stale, runs its function, which calls b.get() and
c.get(), each of which recomputes from a. Dependency order comes out of the
call stack rather than out of a scheduling decision:
$ node twopass.mjs
d ran 1 time(s): 4,12One run, correct values, and the same forty-odd lines a signals runtime is
built on.
The if (d.state === STALE) continue guard is what keeps the marking pass linear
in a diamond — d is reachable through both branches and gets marked once.
Laziness falls out of the same split
Because the marking pass carries no values, it also does no work. That is not a separate optimization; it is the same property seen from the other side:
$ node twopass.mjs
after set, recomputations without any read: 0
leaf 0 read -> 1 | recomputations now: 1Five dependents marked stale, zero functions called, and reading one of them recomputes exactly one. This is the same pull discipline that makes iterator helpers cheap: nothing is produced until a consumer asks, and the asking is what determines how much runs.
The cost of getting it wrong compounds
One diamond costs one extra recomputation, which is easy to dismiss. Stack them — each level’s join feeding the next level’s two branches — and the single-pass version stops being a correctness argument and becomes a complexity one:
$ node chain.mjs
levels | single-pass recomputations | two-pass recomputations
1 | 4 | 3
2 | 12 | 6
4 | 60 | 12
8 | 1020 | 24
12 | 16380 | 36Two passes are linear in the number of nodes — exactly 3n, one recomputation
per node. A single pass revisits every shared node once per path that reaches it,
and in stacked diamonds the number of paths doubles per level. Twelve levels is
not an exotic graph for a form with derived validation, and 16,380 against 36 is
the difference between the two designs on it.
Two real libraries, same answer
The forty lines above are not a toy that happens to work. The same diamond, in two production libraries:
$ node vp.mjs
vue diamond runs: 1 ["4,12"] | effect reruns when intermediate unchanged: 0
preact diamond runs: 1 ["4,12"] | effect reruns when intermediate unchanged: 0Vue 3.5.42 and Preact Signals 1.14.4 both give the single correct run. The second column shows something the minimal implementation does not do, which is the subject of the next section.
Where two passes do redundant work
The marking pass is unconditional. It walks every transitive dependent and sets a flag, without any idea whether the values will actually differ. Consider a computed that clamps:
const y = computed(() => Math.min(x.get(), 1), [x]);
const z = computed(() => { runs++; return y.get() }, [y]);Move x from 1 to 2. y was 1 and stays 1. Nothing downstream has any
reason to run, and in the minimal implementation it runs anyway:
$ node unchanged.mjs
minimal two-pass, recomputations of z: 1 | y is still 1The libraries measured above return 0 for the same case, because they add a
third idea on top of the two passes: when a stale node recomputes and its value
is unchanged, its dependents are released rather than recomputed. That check is
what the unchanged: 0 column reports, and it is a separate mechanism from
glitch freedom — you can have either without the other.
So the honest summary has two halves. Against a single-pass push, two passes buy both correctness and the linear scaling above. Against doing nothing, they still cost a full walk of the dependent set on every write, and they will happily recompute a node whose inputs settled on the values they already had. Only the equality cutoff fixes that, and it is a third mechanism rather than a consequence of the first two.
What to take from this
If you are reading a signals implementation, the two functions to find are the one that sets flags and the one that recomputes on read. Everything else (batching, ownership, disposal) is scheduling around those two. If you are debugging a reactive system that produced an impossible pair of values, the question is whether some path in it pushes a value rather than a marker; a manual subscription bridging two libraries is the usual culprit.
What I could not measure: Solid 1.9.15. Its effect queue does not flush under
plain node without the framework’s runtime entry, and its memos did not
invalidate in that environment either, so every number I got from it describes my
harness rather than the library. Testing it properly needs Solid’s own test setup,
and I would rather report nothing than report that.
FAQ
Frequently asked
What is a glitch in a reactive system?
A moment where a derived value is computed from inputs that are not all from the same update. In the diamond here, the leaf reads a fresh 4 from one branch and a stale 11 from the other, producing 4,11 — a pair that no assignment ever created.
Why do signal libraries mark dependents instead of pushing new values?
Because a push carries a value, and a value can only be correct if every other input is already updated. A marker carries no claim about anything. The push pass sets flags, the pull pass recomputes in dependency order, and no intermediate is ever observed.


