# 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.

- Published: 2026-10-04
- Tags: frameworks, signals, javascript
- Source: https://jsledger.com/blog/why-signals-push-a-marker-and-pull-a-value/
- Language: en-US
- Author: Jonah Vail

---
Here is a reactive graph in the shape everybody draws: one source, two derived
values, one leaf that reads both.

```js
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,12
```

The 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](/blog/signals-are-not-magic/) 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:

```js
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:

```js
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,12
```

One 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: 1
```

Five 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](/blog/iterator-helpers-are-lazy/): 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 |                      36
```

Two 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: 0
```

Vue 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:

```js
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 1
```

The 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.
