RxJS remains the go-to tool for complex async data flows and event-driven systems in 2026. Angular Signals now handle most synchronous state, while RxJS remains essential for streaming data.

In most medium and large codebases I review, the biggest problem is unclear ownership between Signals and RxJS. It is the absence of a clear decision boundary between the two. Signals handle state that exists right now. RxJS handles values that arrive over time and need coordination, cancellation, or transformation. When teams blur that line, streams become harder to follow, subscriptions outlive their components, and debugging takes hours. This guide shows how to keep that boundary clear.

Console + tap

For simple inspection, use the tap operator. It lets you inspect the stream without changing its values.

Quick Logging

For a quick check, use tap(console.log) to log every emitted value:

of(1, 2, 3).pipe(

  map(v => v * 2),

  tap(console.log)

).subscribe();

Conditional Debugging (Production-ready)

To keep debug logs out of production, wrap tap in a helper that checks an environment flag:

const isDebugMode = true; // Set to false in production

const debug = <T>(label: string): MonoTypeOperatorFunction<T> => tap(value => {

  if (isDebugMode) {

    console.log(`[${label}]:`, value);

  }

});

// Usage

of(1, 2, 3).pipe(

  debug(‘DataStream’),

  map(v => v * 2)

).subscribe();

This keeps logging consistent and lets you turn it on or off globally.

Angular DevTools + Chrome Performance

Angular debugging goes well beyond console logs. Use these tools to track down harder problems:

  • Angular DevTools (Components & Profiler): Use the official extension to inspect the component tree and analyze change detection cycles. The Profiler helps you find performance bottlenecks by showing which components update and what triggered them.
  • Chrome Performance Profiling: For a more detailed view of Angular performance, run ng.enableProfiling() in the browser console. This adds an Angular track to the Chrome Performance panel so you can see change detection and lifecycle activity on the timeline. Note: ng.enableProfiling() is intended for development mode only and requires a modern browser and Angular version 19+.
  • Async Debugging via Sources: For harder RxJS bugs, open the Sources panel. Set breakpoints within your observable operators (like map or tap). When execution pauses, use the call stack to trace how the stream reached that point.

Modern Subscription Lifetime Management

The safest subscription is one Angular manages for you. For managing streams in Angular 2026+, follow this hierarchy:

  • Declarative Approach (Async Pipe / toSignal):
    1. Use async pipe in templates or toSignal() in logic. Angular handles cleanup automatically, so you do not need to manage the subscription yourself. Prefer this over calling .subscribe() manually.
  • When you need a manual subscription for a side effect, use takeUntilDestroyed() by default.
    1. If you must subscribe manually (e.g., for Side Effects), takeUntilDestroyed() should be the standard. It automatically unsubscribes when the context is destroyed.
    2. In the constructor/initializer: .pipe(takeUntilDestroyed()).
    3. Outside the injection context: takeUntilDestroyed(this.destroyRef).
    4. Using DestroyRef in lifecycle hooks:

private destroyRef = inject(DestroyRef);

ngOnInit() {

  this.data$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe();

}

  • When to use manual takeUntil:
    1. Use takeUntil(this.destroy$) only when the subscription must end before the component is destroyed (e.g., when a condition changes that requires early disconnection of the stream).

Why does this hierarchy matter in production? Every manual subscription creates a cleanup responsibility. A short-lived component may hide the problem. In a long-lived service, a dashboard that stays open for hours, or a feature that gets navigated in and out repeatedly, forgotten subscriptions become a slow memory leak that only shows up under real user load. toSignal and the async pipe remove that commitment entirely. takeUntilDestroyed makes the commitment automatic. The classic takeUntil + Subject pattern should remain the exception — useful only when you need to complete a stream earlier than the component lifecycle. Teams that follow this hierarchy spend less time tracking down memory leaks later.

Error Handling

Handle shared HTTP failures globally and recover from stream-specific failures close to the stream.

Global Error Handling

Use an HttpInterceptor to centralize error logic across your application.

@Injectable()

export class ErrorInterceptor implements HttpInterceptor {

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {

    return next.handle(req).pipe(

      catchError(error => {

        // Global logic (e.g., logging to service or toast)

        console.error(‘Global Error Handler:’, error);

        return throwError(() => error);

      })

    );

  }

}

Local Error Handling & Retry

For individual streams, use retry with backoff, then return a safe fallback from catchError when recovery makes sense.

import { timer, throwError, of, catchError, retry } from ‘rxjs’;

data$.pipe(

  retry({

    count: 3,

    delay: (error, retryCount) => timer(retryCount * 1000)

  }),

  catchError(err => {

    console.error(‘Local stream error:’, err);

    return of([]); // Return safe default

  })

).subscribe();

Signals ↔ RxJS Interop

While RxJS remains the standard for complex asynchronous streams, Angular Signals are preferred for managing synchronous state. Use explicit conversion points to keep the code easy to follow.

When to use toSignal()

Use toSignal() when a template needs the current value from an Observable. Angular manages the subscription, and the template updates when the Signal changes.

const data = toSignal(

  this.http.get<{value: number}>(‘api/data’).pipe(

    map(d => d.value)

  ),

  { initialValue: null as number | null }

);

When to use toObservable()

Use toObservable() when a Signal needs to enter an RxJS pipeline.

toSignal and toObservable do more than save boilerplate. They define the boundary between state and streams. They mark deliberate transition points in the data flow. When you convert an Observable into a Signal, you are making a conscious decision: “From this point forward, this value is current state.” When you go the other direction, you are saying: “I need the power of operators again.” Keeping these conversion points explicit and few makes the mental model of the system much easier to hold. New engineers can follow the data flow without having to reverse-engineer why a particular piece of state is sometimes a Signal and sometimes an Observable. This discipline becomes more valuable as the codebase grows. Over time, it helps the team change behavior without breaking unrelated parts of the system.

Debugging Mixed Streams

When Signals and Observables interact, inspect both sides of the conversion:

  • Observable Source: Use tap() inside your pipeline to check values before they turn into Signals.
  • Signal Consumption: Use effect() to log updates to computed signals, or just look at the value directly in your template during development.

Performance and Memory Leaks

To keep your Angular apps fast, you need to watch your RxJS subscriptions. Leaks happen when subscriptions aren’t cleaned up, causing high memory use and slow interfaces. By 2026, the Angular DevTools Profiler can spot these excessive subscription counts automatically.

What a subscription leak looks like in production: The most common pattern I see is a component that subscribes to a long-lived stream (user settings, WebSocket updates, a shared store) and never cleans up. Each time the user navigates to that route and back, another subscription is added. After a few dozen navigations the browser tab starts consuming more memory, change detection slows down, and eventually the tab becomes unresponsive. These issues often stay hidden during local development and short QA sessions, then appear under sustained use. Use automatic cleanup by default, then check long-lived screens with Angular DevTools and heap snapshots.

Recommendations:

  • Use async pipe in templates. It manages subscriptions automatically, prevents leaks, and avoids the need to call unsubscribe.
  • Avoid scattered subscriptions. Combine related streams when it makes the flow easier to follow.
  • Use the Angular DevTools Profiler to see which updates trigger component work.

Short Checklist: What to check first

  • Check whether the code can use the async pipe or toSignal instead of a manual subscription.
  • Check for `takeUntilDestroyed` in manual subscriptions.
  • Verify `shareReplay` configuration (`bufferSize: 1, refCount: true`).
  • Use Angular DevTools Profiler to check change detection cycles.

Practical 2026 Debugging Patterns

Debug reactive streams in a consistent order:

  • Conditional Logging via tap: Wrap debug logging in a helper controlled by an environment flag. This keeps debug code available without sending logs in production.
  • Angular DevTools Profiler + Chrome Performance:
    • Use the Profiler to analyze Change Detection cycles.
    • Enable ng.enableProfiling() and open the Performance tab to see the special “Angular” track. This helps you match RxJS activity with component updates and spot long tasks.
  • Checking for Memory Leaks:
    • Use the Performance -> Memory tab (Heap Snapshot).
    • Take Heap Snapshots before and after navigating between pages. Look for objects that remain reachable after their components should have been destroyed.
  • Validating shareReplay:
    • For one-value shared caches, start with { bufferSize: 1, refCount: true } and confirm that the behavior fits the use case.
    • refCount: true ensures the stream stops when subscribers disappear. To verify, add a tap with a log upon subscription and a tap with an unsubscribe (or finalize) callback. Use finalize to confirm that the source is torn down when the last subscriber leaves.

Decision: RxJS vs Signals

Choose based on whether the value represents current state or work unfolding over time:

Choose RxJS when the work needs cancellation, coordination, retries, or time-based operators. Choose Signals when you are dealing with current UI state or simple derived values.

Updated Code Examples

This example combines RxJS and Signals in a typeahead search. It uses inject() for dependencies and toSignal() to expose the results to the template.

import { Component, inject } from “@angular/core”;

import { FormControl, ReactiveFormsModule } from “@angular/forms”;

import { HttpClient } from “@angular/common/http”;

import { takeUntilDestroyed, toSignal } from “@angular/core/rxjs-interop”;

import { debounceTime, distinctUntilChanged, switchMap, catchError, of, retry, timer } from “rxjs”;

interface SearchItem { id: string; name: string; }

@Component({

  selector: “app-search”,

  standalone: true,

  imports: [ReactiveFormsModule],

  template: `

    <input [formControl]=”searchControl” placeholder=”Search…”>

    <ul>

      @for (item of results(); track item.id) { <li>{{ item.name }}</li> }

    </ul>

  `

})

export class SearchComponent {

  // Injection context

  private http = inject(HttpClient);

  searchControl = new FormControl(“”);

 

  // Stream -> Signal pipeline

  results = toSignal(

    this.searchControl.valueChanges.pipe(

      debounceTime(300),

      distinctUntilChanged(),

      switchMap(term =>

        this.http.get<SearchItem[]>(`/api/search?q=${term || ”}`).pipe(

          retry({ count: 2, delay: () => timer(1000) }),

          catchError(err => {

            console.error(“API Error”, err);

            return of([] as SearchItem[]); // Fallback to empty state

          })

        )

      ),

      takeUntilDestroyed() // Auto-unsubscribe on component destroy

    ),

    { initialValue: [] as SearchItem[] }

  );

}

Conclusion

In modern enterprise codebases, RxJS and Signals work best when the boundary between them stays intentional. Use signals for the state that is current. Use RxJS for work that unfolds over time. Keep the conversion points explicit. Prefer automatic lifetime management by default. These habits look small day to day. Over time they determine how easy the system remains to change and debug as the product and the team grow.