JavaScript Reactivity (Part 4): Push vs. Pull Reactivity

Published 2026-07-23, About 15 minute read.

In the docs for RxJS there is a tidy table that lays out the distinction between push and pull over single and multiple values as arguments:

SINGLE MULTIPLE
Pull Function Iterator
Push Promise Observable

What tidiness! What pure, Platonic satisfaction it gives! Of course, this table is nice, but only as far as it markets why RxJS exists: push strategies need a primitive. (And, I wonder, is this a fundamental leaning that all languages succumb to?)

You can actually adjust this table to include more examples, which might help illustrate these four quadrants better:

SINGLE MULTIPLE
Pull
  • Function
  • Getter (get foo())
  • Plain property / variable read
  • Thunk/callback (() => value)
  • Iterator / generator (function*)
  • Any iterable (Symbol.iterator)
  • Cursor / next() / paginated fetch
Push
  • Promise
  • Callback (before promises, like (err, value) => {})
  • requestAnimationFrame / setTimeout
  • once: true event listener
  • Events (addEventListener)
  • Observable

After thrashing about in the reactive framework landscape for the last few months I've noticed a few themes:

There really isn't a good example of a pure push framework 📎

The "most push-iest" of all the frameworks seems to be Svelte before runes. Up to and including Svelte 4, the reactivity was wholly wired up at compile-time with the svelte compiler. When a value was invalidated, all calculations and dependencies were figured out (and guessed at) at compile time. When a value changed there was literally a function already created that got called to handle that state change and affect all dependencies for that piece of state. This was all before running any javascript.

Why is this the pushiest? At run time the connective tissues of dependencies and dependency chains were already figured out. Values wouldn't have to be pulled when the template was re-rendered- we already have all the actions that need to happen to update the DOM. We have a function for that, provided by the Svelte compiler. Svelte works out the reactivity statically beforehand. It's this compile-first approach that led Rich Harris to imagine a framework that gets "compiled away" into vanilla javascript.

This led to some real issues, though. You may remember I noted the compiler sometimes "guessed" at dependencies. There are certain situations where the compiler just couldn't know there is a dependency that needs to be associated without workarounds. Here is an example (and you can see it in the playground here):

The way that the computed variable is structured, you would expect the doubled variable to be reactive. Svelte 4 relied on understanding dependencies by seeing what variables are used in the computed $: statements. Svelte misses the fact that count is a dependency of the double variable, because count is not used in the computed definition and Svelte does not inspect the double() function body. How do we make it work? Force count to be recognized as a dependency by artificially passing it as an argument:

This is not ideal. There is clearly a something in the reactivity that gets missed when analyzing statically. Do we start to statically analyze deeper? Or do we make some "concessions" and start to include signals to the Svelte compiler that are more explicit about what is reactive? This is where runes come in, and that's starting to take Svelte into signal-land.

It's really difficult to pin down what would make a "push" framework 📎

All frameworks or reactivity systems need to "nudge" the system in some way. In React, using a setState() hook will schedule re-renders generally. In Ember, setting a tracked property will nudge a clock. In wc-services, notify() is called. With signals, tracked dependencies are directly informed.

If this little "push" (or state invalidation, or notification, or signal, or dirtying) were technically enough to qualify the whole framework as "push", all frameworks would be considered push-pull and the push-to-pull axis that I've been talking about collapses and is meaningless. It would turn out that all frameworks just have both. But I feel in my heart of hearts that there is a meaningful difference between push-pull and pull strategies. And looking out at the discussions on frameworks this is not just my assessment.

There is something more than just nudging the framework with an invalidation to qualify as push in my mind. It has to do with dependencies and how the system solves the diamond problem. In React, Ember, Angular (with Zone.js), and Lit, the fact that everything is pulled during the render process means that we don't run into state inconsistencies because the whole state tree used to render is freshly pulled. But in signals frameworks like Solid, Preact, and Svelte 5, the framework now has to be concerned with dependencies and consistency in state changes.

My thesis is that labelling a framework as push doesn't mean that there is an invalidation or state change notification. Every framework has that. It means that dependencies of the reactive data are figured out ahead of time.

The diamond problem and glitches 📎

We need to dig into the diamond problem and how this occurs with signals-based frameworks.

So, we're going to make a new signal system called "sig," because it's a naive implementation of signals. Our goal here is that we want effects to be run when a consumer changes state. Effects include rendering, so in our toy framework, you could assume that rendering could be scheduled after a signal alerted sig of changes. For our purposes, we will just run a callback and console.log

effect tracks when a signal is consumed and tracks the effect function on the signal in a list of subscribers. When the signal value is changed, the signal goes through each subscriber and re-runs each effect function.

Now we introduce the problem: diamond dependencies. If we create derived state that is situated in a diamond hierarchy, we will notice a glitch. Values will redundantly be recomputed, and for a brief period effects will run with incorrect values. (And remember, the effects here are where the render of the template would happen in our framework.)

If you trace the effects that are run synchronously after A.value is set to 2, the "D" effect has an intermediate run that produces a wrong value!

Why? Let's walk through the runtime as it executes:

There are three signals that represent the top three entities in our dependency graph- A, B, and C.

A becomes a dependency of B here. This creates one "subscription" that sig needs to handle for A.

A becomes a dependency of C also. This creates another "subscriber" that sig needs to handle and track.

The D effect reads B.value and C.value, so it subscribes to both B and C. On the first run (before we trigger any change) everything is computed correctly. B is already 1, C is already 1, and D is then calculated to be 1 + 1 which is 2. So far, so good.

Setting A's value triggers all effects to run synchronously and in order. First, the A->B->D chain, then the A->C->D chain.

First subscriber runs B.value = A.value and sets B to 2. Writing B fires its set trap, which notifies B's subscriber D.

The D effect logs out C + B, but it's incorrect! D re-runs before the C effect has updated C's value. In this case, then, the value will be 1 + 2 = 3. (If the C effect had run, it would be 2 + 2 = 4!)

Now A continues to its second subscriber: C.value = A.value sets C to 2. Writing to C's value fires its set trap, which notifies its subscriber D. This is a second call for D's effect now.

D reads B.value (2) and C.value (now correctly 2) and logs D: 4. This is correct, but we had an incorrect state for a quick second!

This ain't great.

Solutions to the diamond problem 📎

Try 1: Let's schedule effects! 📎

You may have noticed we ran into this issue because we eagerly ran effects synchronously. What if we scheduled effects and just took note if we were trying to run an effect twice? Then, when we want to flush the effects, we could be sure that effects run in order and effect chains don't duplicate effects.

Below is a bare-bones scheduler example. Instead of actually running effects, we schedule them, and then "flush" the effects all at once after the updating work is all scheduled out. It's a very neat trick, relying on queueing a flush as a microtask so that the flush happens after all the scheduling work is complete.

It's fixed, right? We can go home now?

Not exactly...

This might be the fix for this small diamond, but what happens if we have larger and more complicated dependency chains? The answer is that scheduling won't solve this completely in all cases.

We could just let this re-render and run effects until everything eventually "settles." But this makes us feel sad because there's no guarantee that a graph of sig dependencies would eventually settle. What if there is a cycle in the graph of reactive data? In the end this is not a strong enough guarantee for us to rely on. We need something more solid and robust.

The other solution is to solve the dependency hierarchy with topological ordering.

Try 2: Topological ordering 📎

Fancy!

The biggest change here is that we have to start explicitly tracking dependencies at run time. The strategy here is that we track derived reactive values and dependencies. Then, we construct a graph of nodes that help us sort the effects in the right order of execution.

That graph of nodes needs to be re-constructed on each notification, because often there can be dynamic or conditional dependencies that show up in an update.

How do we know what order is correct? If a data change is signalled, we look at the depth of dependency chains at that point. We sort the node chains by depth. Once we're there, we can run the effects in order knowing that there will be no chains of reactivity competing like before.

So, forgive me, but this example is going to get big. It's adding two things: a constructed dependency graph for reactive data, and a topological sort that schedules updates in order.

This is getting pretty abstract and recursive, but the important point is that dependency chains are analyzed and sorted before effects are run in a batch. In my estimation, this glitch-free ordering is the central problem that fine-grained push systems have to solve. Pull frameworks don't run into this same issue because they do a full pull of all render functions (effect functions) from top to bottom. They never see that intermediate glitch.

In summary 📎

We've discovered that languages naturally start with pull data flow, and push actions and behaviors often need to be discovered and added later.

The big, hairy problem a fine-grained push framework has to solve is that state change dependencies need to be tracked and investigated before effects are run. Pull frameworks sidestep this problem by re-running every render function from top to bottom.

Topological sorting and scheduling are tools to ensure proper dependency resolution in push-based scenarios. These allow frameworks to be fine-grained, but are more onerous to track and keep consistent.

Hope you are enjoying these as much as I enjoy writing them. Next up, a bit of philosophy: reactivity as cache invalidation (coming soon!)

Find me on Bluesky or Mastodon. I also have an RSS feed here

⬅️ Previous Post
Back to top