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 |
|
|
| Push |
|
|
After thrashing about in the reactive framework landscape for the last few months I've noticed a few themes:
- Frameworks gravitate towards "pull" actions by the nature of the problems they solve. This is a result of the problems they have to solve when rendering. The diamond problem and consistency in dependency chains seem to be the main determinant in whether a framework is push/pull or just full pull.
- Programming languages generally don't start with "push" in their design. In the table you can see that the push row developed later in Javascript's life- promises were included in the Ecmascript standard after A+ was formulated, and observables have no comparable primitive in the standard today.
- There really isn't a pure push framework. The closest I can find is Svelte 4, but even that could be considered slightly pull.
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):
Doubled: {doubled}
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