Signals: The Push-Pull Based Algorithm
- title
- Signals: The Push-Pull Based Algorithm
- type
- summary
- summary
- How signals work: push-pull reactivity with automatic dependency tracking, built from scratch
- tags
- reactive-programming, signals, javascript, typescript
- sources
- signal-push-pull-algorithm
- created
- 2026-04-06
- updated
- 2026-04-06
Willy Brauner walks through how signals actually work under the hood, building a complete implementation from scratch in about 80 lines of TypeScript. The core idea is a two-phase algorithm: push invalidation eagerly, pull re-evaluation lazily.
The spreadsheet metaphor
A cell y = 2 * x automatically updates when x changes. That's reactive programming โ changes in data sources propagate through a graph of dependent computations without you manually wiring the updates. JavaScript picked this up starting with Knockout.js (2010) and RxJS (2012), and the pattern now drives Solid, Vue, Preact, Angular, and Svelte.
Signals: the push side
A signal holds a value and a set of subscribers. When you write to a signal, it immediately notifies every subscriber โ this is the push. The notification says "something changed" (invalidation), not "here's the new value." Subscribers get marked dirty but don't recompute yet.
set value(v: T) {
if (value === v) return
value = v
for (const fn of Array.from(subs)) fn(v)
}
Equal-value writes are skipped, so setting a signal to its current value is a no-op.
Computeds: the pull side
A computed wraps a function that derives a value from one or more signals (or other computeds). It caches the result and only recomputes when actually read โ and only if it's been marked dirty. This is the pull. If nothing reads the computed, it never recomputes, even if its dependencies changed ten times.
get value() {
if (dirty) _internalCompute()
return cachedValue
}
The dirty flag is the bridge between push and pull. Push sets it; pull clears it.
Automatic dependency tracking
The trick that eliminates manual dependency arrays (React's useEffect deps) is a global stack. When a computed runs its function, it pushes a context object onto the stack. Any signal that gets read during that execution checks the stack, finds the current computed, and subscribes it. When the computed re-runs, it first cleans up old subscriptions and re-tracks from scratch โ so conditional branches that stop reading a signal will stop being subscribed to it.
// Inside signal's getter:
const currentComputed = STACK[STACK.length - 1]
if (currentComputed) {
subs.add(currentComputed.setDirty)
currentComputed.addSource(() => subs.delete(currentComputed.setDirty))
}
Nested computeds work the same way โ a computed that reads another computed subscribes to it through the same stack mechanism. Invalidation propagates up the chain: signal marks computed A dirty, which marks computed B dirty, and so on. But no actual recomputation happens until someone reads the outermost value.
Execution flow
const count = signal(1)
const doubleCount = computed(() => count.value * 2)
const plusOne = computed(() => doubleCount.value + 1)
count.value = 5 // push: marks doubleCount dirty, which marks plusOne dirty
console.log(plusOne.value) // pull: plusOne recomputes, which triggers doubleCount to recompute
// doubleCount: 5 * 2 = 10, plusOne: 10 + 1 = 11
TC39 standardization
The tc39-proposal-signals (Stage 1) aims to build this into JavaScript natively. The idea isn't to replace framework APIs but to provide a shared reactive primitive underneath โ frameworks keep their own surface API but share the subscription and invalidation machinery.
Related
- reactive-signals โ the concept page for the signal pattern in general
- Production implementations (alien-signals, preact-signals, solidjs-signals) add optimizations on top of this basic algorithm, but the push-pull core is the same.