"Should I use Signals or RxJS for this?" is the most-asked question in modern Angular code review. The bad answer — "use Signals everywhere, RxJS is legacy" — produces fragile WebSocket handling and re-invented switchMap. The other bad answer — "stick with RxJS, Signals are toys" — produces 30 lines of BehaviorSubject + takeUntilDestroyed to track which tab is active.
The right answer is mechanical, not philosophical: Signals are pull-based, synchronous, fine-grained reactive values; RxJS is push-based, asynchronous, time-aware event composition. Each is excellent at exactly one half of the problem. Real code, benchmarks, and production numbers from migrating Mattrx — a multi-tenant marketing-analytics SaaS (540 components, 22k LOC, 110k MAU).
The mental model (the one chart you need)
SIGNALS RxJS
Direction PULL (read latest) PUSH (subscribe, get next)
Time SYNCHRONOUS ASYNCHRONOUS
Granularity Per-value, fine-grained Per-stream, coarse-grained
Cancellation N/A (no async) takeUntil / abort
Memoization Built-in (computed) Manual (shareReplay)
OnPush integration Auto-marks for check Needs async pipe / markForCheck
Conceptual fit "the current value of X" "the stream of X events over time"
Ask one question:
- "I need to know the latest X" -> Signal
- "I need to react to every event of X over time" -> RxJS
That resolves 90% of the real ambiguity.
When Signals win (state)
Local UI state, derived values, service-level shared state, side effects — all Signal territory. The service-level case is where the code shrinks most:
// BEFORE - BehaviorSubject ceremony (29 lines)
private dateRange$ = new BehaviorSubject<DateRange>(...);
private status$ = new BehaviorSubject<Status[]>(['active']);
// ...asObservable(), setters, combineLatest, shareReplay...
// AFTER - Signals (9 lines)
readonly dateRange = signal<DateRange>({ from: lastWeek(), to: today() });
readonly status = signal<Status[]>(['active']);
readonly filters = computed(() => ({ dateRange: this.dateRange(), status: this.status() }));
computed() is the multicast — lazy, memoized, O(1) cached reads. No shareReplay, no combineLatest, no next(). Mattrx had 240 BehaviorSubjects; the cleanup deleted ~1,400 lines of state code.
When RxJS wins (events over time) — and Signals genuinely can't
Debounced search with race cancellation is the canonical example:
results$ = this.query.valueChanges.pipe(
debounceTime(250),
distinctUntilChanged(),
switchMap(q => this.http.get(`/api/search?q=${q}`).pipe(catchError(() => of([])))),
shareReplay(1),
takeUntilDestroyed(),
);
To replicate this in pure Signals you'd hand-roll a setTimeout debounce, an AbortController you abort per keystroke, try/catch, and a loading flag — ~40 lines re-inventing switchMap. Same story for WebSocket streams (webSocket() + retry({delay}) + share()), drag-to-resize (mousedown -> switchMap(mousemove) -> takeUntil(mouseup)), and multi-source merge (combineLatest + throttleTime). Anything with debounce / throttle / retry / timeout / switchMap / scan in its description belongs in RxJS. Signals do not model time.
Performance (numbers, not vibes)
| Operation | Time |
|---|---|
signal() read |
~12 ns |
signal.set(v) write (no listeners) |
~50 ns |
BehaviorSubject.next(v) (one sync sub) |
~700 ns |
computed() read (warm cache) |
~15 ns |
Observable pipe(map).subscribe()
|
~3.4 us |
Signals are ~14x faster than BehaviorSubject for "publish a value, one sync reader." But the real win is structural, not micro: on Mattrx's /inbox, re-renders per WebSocket message went 23 -> 2 — because fine-grained dependency tracking re-renders only the specific slice that changed, not 23 components subscribed via | async. And with zoneless + Signals, idle change-detection passes on /dashboard dropped to 0 (only run when a signal changes).
The honest caveat: Signals don't make a slow app fast on their own — they make change-detection precise. The big wins come combined with OnPush + standalone + @for track.
Interop — you don't have to pick
Bridge at the boundary and keep each half native:
// stream -> state (HTTP as an OnPush-aware Signal)
campaigns = toSignal(this.http.get<Campaign[]>('/api/campaigns'), { initialValue: [] });
// state -> stream (debounce a Signal-driven input through RxJS, back to a Signal)
results = toSignal(
toObservable(this.query).pipe(debounceTime(250), distinctUntilChanged(),
switchMap(q => this.http.get(`/api/search?q=${q}`))),
{ initialValue: [] },
);
toSignal() and toObservable() aren't workarounds — they're the design. (One gotcha: toSignal without initialValue returns Signal<T | undefined> — always supply initialValue for HTTP.)
The decision tree
Is this "the current value of X" (state)? -> SIGNAL
├── Local to a component -> signal()
├── Shared across the app -> signal() in a service
└── Derived from other signals -> computed()
Otherwise it's an event stream / time-based / async:
├── HTTP -> HttpClient Observable; toSignal() if read as state
├── debounce/throttle/switchMap input -> RxJS
├── WebSocket / SSE -> RxJS (webSocket + retry + share)
└── composing DOM events (drag, gesture) -> RxJS (fromEvent + operators)
The right mental model
In one line: Signals are nouns. RxJS is verbs. "What is the current set of selected campaigns?" -> Signal. "What's happening when the user types?" -> RxJS. Three habits: default to Signals for state, default to RxJS for streams, bridge with interop instead of fighting it.
The full guide has all 10 code examples each way, the full benchmark tables, the end-to-end /campaigns component using both, and the 4-week Mattrx migration path (including the bugs that bit us):
https://prepstack.co.in/blog/angular-signals-vs-rxjs-when-to-use-which-performance-comparison-guide
Originally published on PrepStack.
Top comments (0)