Observer
setInterval(check, 100) lives in every widget on a dashboard, each one asking the counter if it changed and mostly hearing 'no'. Ten widgets, one slow source, thousands of wakeups a minute for a number that ticks once.
- ▸Hand a function to an object, it'll keep calling it long after you forget.
- ▸Polling asks a hundred times to hear 'no change', the list-walk runs only when something really changed.
- ▸The same six-line registration sits behind
addEventListener,store.subscribe, andpost_save.connect, every callback API is one mental model.
Open a long-running dashboard and check the dev tools profiler. You'll see something like this everywhere:
setInterval(() => {
if (counter.value !== prev) {
render(counter.value);
prev = counter.value;
}
}, 100);Every widget, clock, chart, status pill, runs its own copy. Each fires ten times a second. Each one asks 'did anything change yet?' and most of the time the answer is no.
The cost is invisible until you put a number on it. Ten widgets each polling at 100ms is 6,000 wakeups a minute. The counter ticks once. The other 5,999 wakeups did nothing.
The real problem isn't the wakeups themselves. It's the locality of knowledge: every widget has to know about the counter, has to track its previous value, has to compare and decide whether to render. The counter, the thing that actually changed, does nothing. It just sits there, waited on.
The polling code has the wrong locality. The widget tracks state about the counter; the counter tracks nothing about the widget. Flip the relationship.
class Counter { constructor() { this.value = 0; this.callbacks = []; } onChange(fn) { this.callbacks.push(fn); return () => { const i = this.callbacks.indexOf(fn); if (i >= 0) this.callbacks.splice(i, 1); }; } tick() { this.value++; this.callbacks.forEach(fn => fn(this.value)); } }
The counter now holds an array of functions. Anyone who cares hands their function in via onChange(fn) and gets back a closure that removes them later. When the counter ticks, it walks the array and calls each function with the new value.
Three structural shifts hide in those eight lines:
- The counter's tick() method is identical whether the array has zero entries, one, or fifty. The forEach line never changes.
- Widgets stop tracking previous values. The counter pushes the current value to them; they render and forget.
- The setInterval is gone. Wakeups happen exactly when changes happen, not before, not in between.
A widget shrinks to counter.onChange(value => render(value)). One line replaces the polling loop, the cache variable, and the comparison.
You've used this pattern many times without naming it.
// Browser button.addEventListener('click', handleClick); // Node emitter.on('data', handleData); // Redux store.subscribe(() => render(store.getState())); // Django post_save.connect(handle_save, sender=Article);
Four different stdlibs, four different verb names, addEventListener, on, subscribe, connect. Underneath: the same list-walk. Each surface keeps an array of callbacks, calls each one when the relevant event fires, and returns some way to remove the callback later.
Two things vary across implementations:
- The verb name. on, subscribe, connect, addEventListener, same operation, different decade and library style.
- The shape of unsubscribe. Redux returns an unsubscribe closure (const off = store.subscribe(...); off()). The DOM uses paired methods (removeEventListener). Node's EventEmitter exposes off. Django offers disconnect.
What stays identical: the Subject keeps the list. The Subject decides when to walk it. The Observer just hands over a function and waits.
React's useSyncExternalStore and .NET's IObservable<T> are the same shape with type-level guarantees added. useSyncExternalStore accepts a subscribe function and a getSnapshot function, push for change-detection, pull for the value itself. The pattern's two flavors made into a hook signature.