Tento článok je dostupný len v anglickom jazyku.
The new reactivity system in Angular appeared quite a while ago, considering the rapid pace of framework development. Since then, all the core reactive primitives have stabilized, and new, higher-level APIs continue to emerge on top of them.
While signals are no longer new, the reactivity system remains a topic of active discussion — both in interviews and on forums. The ongoing debates are fueled by the continued presence of RxJS observables in the ecosystem, which represent a contrasting reactivity model.
In this article, we will explore two coexisting approaches from the perspective of computational efficiency, and how implementing reactivity based on the push-pull algorithm has allowed Angular to significantly reduce computational overhead.
A brief introduction:
Our comparative analysis of the reactivity models will be based on the following criteria:
- ⚡ Initiation and delivery of changes
- 🔁 Propagation of equivalent changes
- 🧩 Dynamic restructuring of the reactive graph
We will examine these criteria through the lens of the current implementations available in Angular, specifically the reactivity systems based on signals and RxJS observables.
Subject of comparison
For the comparison, we’ll focus on two reactive primitives with state: WritableSignal from @angular/core and BehaviorSubject from rxjs. Although RxJS supports multiple event types (next, error, and complete), we’ll consider these primitives specifically as reactive nodes that:
- 🏳️ Have an initial state
- ✏️ Allow updating that state
- 🔗 Propagate state changes to interested consumers
This article assumes you are already familiar with the basic APIs around these primitives, enabling the declaration of derived computations and side effects.
⚡ Initiation and delivery of changes
Observables
RxJS observables follow a push-based reactivity model, in which the producer itself triggers computations and actively delivers updates to its consumers:
next(value: T) {
if (!this._closed) {
const { observers } = this;
const len = observers.length;
for (let i = 0; i < len; i++) {
observers[i].next(value);
}
}
}
The main issue with instant push-based reactivity is the inevitable redundant computations, which can lead to inconsistent states. This can be traced by representing the dependencies as a directed acyclic graph:
In the illustration above, one can notice the well-known diamond problem, where a source node affects a target node through multiple paths. The push model does not account for the fact that both paths lead to the same node and recalculates the value for each path separately.
In RxJS, this problem can be represented by defining a derived state that depends on the same source node through multiple reactive nodes:
// Producer node
const A = new BehaviorSubject(1);
// Two derived nodes B and C, both depend on A
const B = A.pipe(map(a => a * 2));
const C = A.pipe(map(a => a + 10));
// Final node D, which depends on B and C
const D = combineLatest([B, C]).pipe(map(([b, c]) => b + c));
A change in a producer node A may traverse multiple dependency paths, causing the same downstream node to be updated multiple times and resulting in temporary inconsistencies in the graph:
In other words, the number of recomputations grows exponentially with the number of paths in the graph. Mathematically, the total number of times a specific consumer node is calculated over the entire lifecycle of the graph can be expressed with the following formula:
A consumer node’s total calculations are determined by summing over all producer nodes, multiplying each source’s update count by the number of unique paths leading to the consumer:
The formula above allows us to predict the performance of reactive computations and understand how an increase in the number of sources and paths significantly amplifies the computational load in the push model.
In the context of a user interface, a notable consequence of this behavior in a reactive system is the temporary rendering of stale state during expensive computations, which can lead to visual glitches or flickering.
Signals
Signals are built on top of a hybrid push-pull algorithm for change propagation. In this model, producer nodes themselves do not initiate recomputation. Instead, setting a new value propagates invalidation downstream to dependent nodes in the graph.
In Angular, it is accompanied by versioning of the producer node:
function signalValueChanged<T>(node: SignalNode<T>): void {
+ node.version++;
}
and marking dependent consumer nodes as “dirty”:
function signalValueChanged<T>(node: SignalNode<T>): void {
node.version++;
+ producerNotifyConsumers(node);
}
export function producerNotifyConsumers(node: ReactiveNode): void {
for (
let link: ReactiveLink | undefined = node.consumers;
link !== undefined;
link = link.nextConsumer
) {
const consumer = link.consumer;
if (!consumer.dirty) {
consumerMarkDirty(consumer);
}
}
}
In the illustration above, you can see that in the combined model, besides propagating “dirty” marks through the reactive graph, a separate mechanism must exist to actually pull values when needed. This mechanism is typically provided by a higher-level system. In Angular, this is handled by the ChangeDetectionScheduler abstraction, which determines when the system should be notified to check for changes.
We can also observe how the diamond problem is solved. In the pull model, the key principle is that an invalidated node is recomputed only upon first access, and subsequent accesses simply reuse its cached value:
This ensures that during graph traversal, each node is computed exactly once, regardless of how many paths lead to it.
Implementing such an algorithm is more complex, as it requires not only constructing the reactive graph and managing node interactions, but also an initiator capable of determining when values should be pulled.
Despite the increased complexity, push-pull provides powerful benefits:
- ⬇️ it reduces computational overhead, preventing exponential recalculations along multiple paths
- 🟢 it keeps nodes consistently up to date, as they independently recompute their values when accessed
For a symmetrical comparison with a pure push model from the previous subsection, we can also derive a mathematical formula to calculate the number of computations for a reactive node in the graph:
As shown by the formula, in the pull model, the number of updates of producer nodes is not multiplied by the number of paths. The resulting count depends solely on the set of relevant consumer node accesses, with each element of the set triggering at most one recomputation.
🔁 Propagation of equivalent changes
In the previous section, we examined that both push and push-pull algorithms traverse the graph when setting values for the producing node (in pure push for initiating recomputations in consumers, in hybrid for their invalidation).
Now, we can evaluate another optimization criterion — whether it is reasonable to initiate a graph traversal if the newly set value is equivalent to the producer’s current state.
Observables
RxJS’s observables have no optimization when pushing equivalent values. This is because RxJS is fundamentally event-driven: each event is considered meaningful on its own, even if the payload hasn’t changed.
However, RxJS allows for manual optimization through operators like distinctUntilChanged, which prevent unnecessary emissions when the producer node’s value hasn’t changed:
const A = new BehaviorSubject(0); // writable
const _A = A.pipe(distinctUntilChanged()); // readonly
📝 You may also have noticed that due to the architecture of RxJS, we cannot optimize the writable producer node itself. Instead, we are forced to create a derived consumer node _A from the source node A.
distinctUntilChanged compares values using === by default, which may lead to unnecessary computations in edge cases due to NaN !== NaN. We can pass a custom comparison function for even finer control:
const _A = A.pipe(distinctUntilChanged(Object.is));
In this setup, we get closer to the optimization that is already built into the default implementation of signals, which will be discussed in the next subsection.
It’s also worth noting that reactive nodes can be not only primitive values but also data structures. This highlights another area for optimization: object comparisons, even with identical structure, always return false.
Object.is([1, 2, 3], [1, 2, 3]); // false
The need for deep structural comparison to detect changes arises when the cost of pushing updates significantly outweighs the cost of comparing a node’s state structures.
The complexity, number of structures, and the frequency and semantics of changes can all affect this choice. Hence, the default implementations of both observables and signals do not perform structural comparisons, leaving it to the developer to decide when such checks are necessary.
Signals
Signals have a built-in value comparison (using Object.is by default). As a result, the producer does not trigger dependency invalidation when the new value equals the one already held by the node:
export function signalSetFn<T>(node: SignalNode<T>, newValue: T) {
if (!node.equal(node.value, newValue)) {
node.value = newValue;
signalValueChanged(node);
}
}
Unlike RxJS subjects, signals provide a way to define a custom equality function right at the creation of the producer node:
import _ from 'lodash';
const A = signal([1, 2, 3], { equal: _.isEqual });
⚠️ We cannot override the default equality function globally for all nodes (at least, Angular does not provide this capability at the time of writing). Therefore, we need to supply it individually for each direct producer in the reactive graph.
For read-only consumer nodes (computed()), which may act as indirect producers if they have dependents, a custom equality function can also be assigned for precise control over a specific branch:
const A = computed(() => source(), { equal: fn });
🧩 Dynamic restructuring of the reactive graph
When discussing reactivity in the context of user interfaces, another important aspect should be mentioned — the ongoing mutability of the reactive graph.
Even within a single view, UI elements can appear and disappear, requiring the release of the resources they consume. These changes are reflected in the reactive graph, where the need to recompute certain nodes may become obsolete or relevant again.
Observables
One of the clearest examples for assessing how efficiently a system uses resources can be a reactive expression that uses a ternary operator:
const A = new BehaviorSubject(1);
const B = new BehaviorSubject(2);
const С = new BehaviorSubject(3);
// Consumer node
const D = combineLatest([A, B, C]).pipe(
map(([a, b, c]) => (a > 0 ? b : c))
);
The reactive graph for this expression looks as follows:
If we represent the behavior of the ternary operator as an if-then-else control-flow graph, we can see that the path to the final node follows only 1 of 2 possible branches, passing through only 2 of 3 nodes:
Consequently, we ensure that consumer node D in our expression always depends only on [A,B] or [A,C]. This makes it possible to safely unsubscribe from the irrelevant producer for optimization:
In a push-based algorithm, where all producers must be known in advance, it is necessary to manually manage subscriptions and unsubscriptions in order to restructure the reactive graph:
const A = new BehaviorSubject(1);
const B = new BehaviorSubject(2);
const С = new BehaviorSubject(3);
const D = A.pipe(
switchMap(a => {
return a > 0 ? B : C;
})
);
The complexity of working with this approach increases as the number of conditional branches grows, since each additional branch requires explicit management of subscriptions to optimize computations.
Subscribing to all sources without adapting the graph is a common pitfall in push-based systems, as it leads to redundant recomputations caused by producers whose changes do not affect the final value.
Signals
In a hybrid push-pull algorithm, consumers determine which values they need, since it is the consumer that initiates the computation during pulling. This allows:
- 🔗 dynamically registering the producer nodes used in the current computation as dependencies
- ⛔ guaranteeing that producers not reached by the current control-flow path will not be accessed and, therefore, will not be included in the list of dependencies
In other words, by using familiar JavaScript expressions or statements when creating a consumer, our subscriptions are managed automatically:
const depD = computed(() =>
depA() > 0 ? depB() : depC()
);
// or
const depD = computed(() => {
if (depA() > 0) {
return depB();
} else {
return depC();
}
});
Signals are implemented as functional objects. Calling a signal within a synchronous tick triggers its recomputation if it has been invalidated, as well as the registration of dependencies.
In this way, the reactive graph adapts automatically by default:
From a developer experience perspective, this approach also has its pitfalls. Most importantly, it can involve non-obvious dependencies that may be hidden within method calls:
// creating a consumer by effect
effect(() => {
this.activityService.logUserView(this.selectedId());
});
Here, a developer may unexpectedly encounter infinite invalidation of a reactive node and repeated pulling of its value:
export class ActivityService {
readonly lastLogged = signal<Date | null>(null);
async logUserView(userId: string) {
await fetch('/api/log-view', {
body: JSON.stringify({
lastLogged: this.lastLogged(), // 🧲 pulling a dependency
userId
}),
});
this.lastLogged.set(new Date()); // ⚠️ invalidation due to new ref
}
}
The example is rather synthetic, but it mainly shows that while automatic dependency registration simplifies working with a reactive graph, it also requires critical thinking to identify where unnecessary dependencies might arise:
When visually assessing our side effect, we consider that it depends on a single node selectedId(). However, under the hood, its computation is affected by two producer nodes:
effect(() => {
// producers: [ 🙉 selectedId(), 🙈 lastLogged() ]
this.activityService.logUserView(this.selectedId());
});
To avoid implicitly registering “invisible” producers as dependencies during their pulling, any call to an external function can be wrapped in the untracked utility:
effect(() => {
/* ──────────────── visual slot for dependencies ──────────────── */
const id = this.selectedId();
/* ───────────────────────────────────────────────────────────── */
untracked(() => {
this.activityService.logUserView(id);
});
});
With this approach, we effectively construct a static graph, where all dependencies are known in advance. This is inevitable to avoid issues when working with external APIs, especially when we cannot inspect the full call chain (e.g., when importing it from a library).
Conclusion
Although this article focused on specific APIs from Angular and RxJS, we can abstract away from these technologies. The key takeaway is a general understanding of the algorithms that enable building an efficient reactivity system, where we can assess the cost of computations and their rationality.
Our deep dive into the technical aspects of the two approaches will allow you to make more confident choices when selecting the appropriate tool — not only within Angular but also across other frameworks.
Don’t forget to take advantage of the notification feature to stay updated on new articles if you found this one interesting 🙂



