
RxJS vs. Signals: The impact on performance and architecture in Angular applications
Anyone building Angular applications for more than a few years has probably developed a sort of Stockholm Syndrome with RxJS. We love it for its robustness in handling complex asynchronous data flows, but we also suffer from the learning curve, the excess of boilerplate, and primarily, the dreaded memory leaks caused by forgotten Subscriptions.
For a long time, RxJS was Angular's answer to everything. Need to make an HTTP request? Observable. Need to listen to a click? Observable. Need to manage a simple "open/closed" state of a modal or a counter? You guessed it: A BehaviorSubject.
This approach of using event-based streams to manage synchronous UI state took its toll on architecture and performance. But with the stabilization of Signals, Angular changed the rules of the game.
In this article, we'll dissect how this shift impacts the code we write every day, dive deep into the real performance gains, and most importantly, architecturally define when to choose one over the other.
The problem with Reactive State using RxJS
To understand the impact of Signals, we need to look at the scars RxJS left on our codebases. Imagine a classic scenario: a checkout screen where you have a product, a selected quantity, and you need to calculate the total price.
In the "classic" Angular architecture with RxJS, the typical implementation would look something like this:
import { Component, OnDestroy } from '@angular/core';
import { BehaviorSubject, combineLatest, map, Subject, takeUntil } from 'rxjs';
@Component({
selector: 'app-checkout',
template: `
<div>
<p>Unit price: {{ price$ | async | currency }}</p>
<button (click)="increment()">+</button>
<p>Quantity: {{ quantity$ | async }}</p>
<h2>Total: {{ total$ | async | currency }}</h2>
</div>
`
})
export class CheckoutComponent implements OnDestroy {
private destroy$ = new Subject<void>();
// State needs to be encapsulated in Subjects
price$ = new BehaviorSubject<number>(150);
quantity$ = new BehaviorSubject<number>(1);
// Derived state requires operators and combineLatest
total$ = combineLatest([this.price$, this.quantity$]).pipe(
map(([price, quantity]) => price * quantity),
takeUntil(this.destroy$) // <- The classic cleanup boilerplate
);
increment() {
this.quantity$.next(this.quantity$.getValue() + 1);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
What is the architectural pain here?
- Cognitive Friction and Boilerplate: A simple math calculation requires
combineLatest,map,takeUntil, andSubject. - Timing and Glitches:
combineLatestonly emits when all inner streams emit at least once. If we aren't careful with initialization, the UI simply doesn't render. - Memory Overhead: Every
asyncpipe in the template creates a separate subscription. Each operator in the pipe chain creates a new Observable instance. In long lists, this destroys performance.
RxJS was designed to coordinate asynchronous events over time (like websockets, input debouncing). Using it to hold the synchronous value of a cart quantity is killing a fly with a cannon.
The Arrival of Signals: Synchronous Reactivity
Signals solve the UI state problem much more cleanly. A Signal is a "wrapper" that encapsulates a value and notifies its consumers whenever that value changes.
Unlike an Observable, a Signal always has a current value (it's synchronous) and lacks the concept of "completing" or "failing".
Let's refactor the same component using Signals:
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-checkout-signal',
template: `
<div>
<p>Unit price: {{ price() | currency }}</p>
<button (click)="increment()">+</button>
<p>Quantity: {{ quantity() }}</p>
<h2>Total: {{ total() | currency }}</h2>
</div>
`
})
export class CheckoutSignalComponent {
price = signal(150);
quantity = signal(1);
// Computed: recalculates automatically ONLY if price or quantity changes
total = computed(() => this.price() * this.quantity());
increment() {
this.quantity.update(q => q + 1);
}
}
In this model, the code looks like pure TypeScript/JavaScript again. No .getValue(), no memory leaks to manage in ngOnDestroy.
Performance Under the Hood: Why are Signals faster?
The syntax change is great for the developer, but the real architectural gain happens in the rendering engine.
The Zone.js Bottleneck
Historically, Angular relied on Zone.js. This library intercepts all asynchronous browser APIs (setTimeout, requests, click events) and tells Angular: "Something happened somewhere. Check if the DOM needs to be updated."
When you click the + button using the classic approach, Angular walks down the entire component tree comparing the old state with the new one (dirty checking). It's an inefficient process, especially in heavy applications.
Fine-Grained Reactivity
With Signals, we enter the era of surgical reactivity.
Angular builds a reactive dependency graph. When you use {{ quantity() }} in the template, the framework creates a direct link between that DOM node and the specific Signal. If quantity changes, Angular doesn't need to check the whole tree. It knows exactly which piece of HTML needs to be re-rendered.
Real impact on performance:
- Zoneless: Signals paved the way to remove Zone.js (
provideExperimentalZonelessChangeDetection()). This reduces the initial bundle size and eliminates unnecessary Change Detection cycles. - Glitch-Free: If
priceandquantityupdate in the same micro-task,total()(which is a computed) will be recalculated only once. In RxJS, multiple synchronous emissions can cause unwanted intermediate renders (UI glitches). - Less pressure on the Garbage Collector: Without dozens of
Subscriptionsbeing created and destroyed on every route navigation, memory consumption drops drastically.
When to choose which? (The Decision Matrix)
The biggest mistake teams make when adopting recent Angular versions is trying to throw RxJS in the trash. Signals do not completely replace RxJS.
They solve different problems. The current architectural golden rule is:
Signals manage STATE. RxJS manages FLOWS AND EVENTS.
Use Signals when:
- Local component state: Simple variables (booleans for modals, counters, synchronous form inputs).
- Derived values: Any data that mathematically or logically depends on another state (using
computed). - Simple global state: Services that store data like
loggedInUseroractiveTheme. - Template Binding: Whenever possible, the variable the HTML consumes should be a Signal.
Use RxJS when:
- Network Requests (HTTP): The lifecycle of a request (pending, success, error, cancellation) is naturally asynchronous and flow-based.
- Time Complexity (Timing): Operations that need
debounceTime,throttleTime, ordelay. Signals don't have a concept of time. - Asynchronous Orchestration and Race Conditions: Needing to cancel request A because request B started (
switchMap), or combining multiple parallel calls (forkJoin). - Push-based events: Websockets, Server-Sent Events (SSE), and DOM event streams (mousemove, drag and drop).
Architectural Approaches: Uniting the Best of Both Worlds
In enterprise applications, the most elegant code today uses Interoperability between the two technologies.
Imagine an advanced scenario: a product search. We need to listen to user typing, apply a debounce to avoid taking down the backend, cancel obsolete requests, handle errors, and finally, display the current state on the screen efficiently.
Modern architecture does this by seamlessly transitioning between Signals and Observables:
import { Component, signal, inject } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { debounceTime, distinctUntilChanged, switchMap, catchError, of } from 'rxjs';
@Component({
selector: 'app-product-search',
template: `
<!-- The UI binding consumes and updates the Signal directly -->
<input (input)="updateSearch($event)" placeholder="Search for a product..." />
<!-- Clean and performant view consumption -->
@if (products(); as results) {
<ul>
@for (product of results; track product.id) {
<li>{{ product.name }}</li>
}
</ul>
}
`
})
export class ProductSearchComponent {
private http = inject(HttpClient);
// 1. STATE: Signal stores what the user types (Synchronous and simple)
searchTerm = signal<string>('');
// 2. FLOW: toObservable() brings the value into the RxJS world
private searchResults$ = toObservable(this.searchTerm).pipe(
debounceTime(300), // Handles time
distinctUntilChanged(), // Prevents redundancy
switchMap(term => { // Cancels pending requests (Race condition)
if (!term) return of([]);
return this.http.get<any[]>(`/api/products?q=${term}`).pipe(
catchError(() => of([]))
);
})
);
// 3. FINAL STATE: toSignal() brings it back for optimized template consumption
products = toSignal(this.searchResults$, { initialValue: [] });
updateSearch(event: Event) {
const input = event.target as HTMLInputElement;
this.searchTerm.set(input.value);
}
}
Why is this the definitive approach?
- Explicit separation of concerns: Where there is state (storage), we use Signals. Where there is action/reaction (asynchronous behavior), we use RxJS.
- Goodbye Async Pipe:
toSignalautomatically manages the subscription according to the component's lifecycle. No more memory leaks. - Maximum Performance: The UI is rendered using Signals' Fine-Grained Reactivity, fully prepared for Angular's Zoneless future.
Conclusion
Adopting Signals in Angular is not just "syntactic sugar". It is a deep refactoring of the framework's engine that fixes historical performance issues (Zone.js) and ergonomics (excessive RxJS for trivial tasks).
Instead of viewing the change as a threat to the RxJS knowledge you've built, see it as a refinement tool. Let RxJS shine at what it does best: orchestrating the messiness of the asynchronous world. And trust Signals to deliver that information to the user interface in the fastest, cleanest, and most performant way possible.
Comments
Loading comments...
Join the conversation
Sign in with your account to comment on this article.