DEV Community

kirandeepjassal-crypto
kirandeepjassal-crypto

Posted on Originally published at prepstack.co.in

Angular Signals vs RxJS in 2026 — When to Use Each, Performance Benchmarks, and the Interop Pattern (Real Code)

"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"
Enter fullscreen mode Exit fullscreen mode

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() }));
Enter fullscreen mode Exit fullscreen mode

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(),
);
Enter fullscreen mode Exit fullscreen mode

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: [] },
);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)