Unsubscribing Observables to Prevent Angular Leaks

A component subscribes to an observable in ngOnInit, never calls unsubscribe() in ngOnDestroy, and the router moves the user to a new page — but the component instance, its template closure, and everything it captured stay resident on the heap because the source observable is still emitting. This sits inside Angular RxJS subscription leaks, part of the broader Framework-Specific Memory Optimization section, and it is the single most common leak pattern reported in Angular applications with long-lived services or WebSocket streams.

Symptom Root Cause Immediate Action
Heap grows per route visit Subscription outlives component Add teardown in ngOnDestroy
Old component in snapshot Closure keeps this alive Use takeUntilDestroyed()
Callback fires post-navigation Source observable never completes Switch to async pipe in template
Duplicate handlers stack up Re-subscribe without cleanup first Consolidate with Subscription.add

Root Cause

RxJS subscriptions are plain JavaScript objects that hold a reference to the observer function you passed to subscribe(). When that observer is an arrow function or a bound method inside a component class, its closure captures this — the component instance itself, along with every property, injected service, and DOM reference it holds. Angular’s change detection and the router do not know anything about RxJS internals: destroying a component via ngOnDestroy only runs the code you write there. If you never call unsubscribe(), the subscription remains registered on the source observable’s internal list of observers, and that list is reachable from the observable itself, which is very often a singleton service that lives for the whole application lifetime.

This is structurally the same problem covered in closure memory leaks: a long-lived object (here, a service-scoped Subject or a WebSocket wrapper) holds a reference to a short-lived one (the component) purely because a callback closure was never released. The garbage collector cannot reclaim the component because a live reference chain exists — this has nothing to do with how mark-and-sweep collection works failing; the collector is behaving correctly by keeping alive everything that is still reachable. The bug is that the reachability graph itself is wrong.

Angular’s zone.js compounds the effect: every emission from a retained subscription re-enters the Angular zone, which can trigger a change-detection pass against a destroyed component’s view, doubling the CPU cost on top of the memory cost. Interactions between subscription leaks and zone overhead are covered separately in Zone.js change detection memory overhead.

Leaking vs. Fixed Subscription Lifecycle Two parallel lanes. The top lane shows ngOnInit subscribing without teardown, so after ngOnDestroy the component instance is still retained by the subscription's observer closure, chained back to a long-lived service Subject. The bottom lane shows the same subscription piped through takeUntilDestroyed, so ngOnDestroy triggers DestroyRef, which completes the stream and releases the component for collection. Leaking: no teardown ngOnInit subscribe(fn) Service Subject observers[ ] ngOnDestroy no unsubscribe Component retained closure keeps observer + "this" alive on the observer list Fixed: takeUntilDestroyed() ngOnInit pipe(takeUntilDestroyed()) Service Subject observers[ ] ngOnDestroy DestroyRef fires Component collected stream completes, observer removed, closure released for GC Heap snapshot comparison view: leaking lane shows +1 retained component per route visit; fixed lane shows 0

Step-by-Step Fix

  1. Confirm the leak with a heap snapshot diff. Open DevTools → Memory → Heap Snapshot, take a baseline snapshot on the component’s route, navigate away, force a collection with the trash-can icon, then take a second snapshot and switch to Comparison view. Filter by your component’s class name. Expected output: a positive # New count for the component class after every visit — for example +1 per route change, confirming it is never freed.

  2. Trace the retainer chain. In the comparison view, click the retained instance and expand Retainers. Look for a path through Subscriberdestination → your component’s zone-patched method. Verification checkpoint: the retainer path should show an RxJS Subscriber object referencing the component, not the reverse — this confirms the observable, not the component, is the anchor keeping it alive.

  3. Convert template-only subscriptions to the async pipe. If the value is only read in the template, remove the manual subscribe() call entirely and bind the observable with | async. Expected output: the component class no longer contains a Subscription field for that stream; Angular’s AsyncPipe subscribes on ngOnInit-equivalent initialization and unsubscribes in its own ngOnDestroy.

  4. Add takeUntilDestroyed() for imperative subscriptions. For subscriptions used to run side effects in class code, inject DestroyRef and pipe every stream through takeUntilDestroyed(destroyRef) before calling subscribe(). Verification checkpoint: re-run the heap snapshot diff from step 1 — the # New count for the component should drop to 0 after the fix.

  5. Consolidate legacy subscriptions with Subscription.add. In codebases on Angular versions before 16, create one Subscription instance per component, add() every subscription to it, and call .unsubscribe() once inside ngOnDestroy. Expected output: a single teardown call replaces N individual unsubscribe() calls, removing the risk of forgetting one when a new subscription is added later.

Command & Code Reference

Broken component: a subscription started in ngOnInit with no matching teardown, so the closure over this is retained by the service’s internal subject for the application’s entire lifetime.

// user-panel.component.ts — leaks on every route visit
export class UserPanelComponent implements OnInit {
  constructor(private userSvc: UserService) {}

  ngOnInit(): void {
    // subscribe captures `this` in the closure below
    this.userSvc.userUpdates$.subscribe(user => {
      this.name = user.name; // keeps component alive
    });
    // no ngOnDestroy — subscription is never released
  }
}

Fixed with takeUntilDestroyed(): the injected DestroyRef completes the piped observable automatically when Angular tears the component down, so the subscriber list drops the reference on its own.

// user-panel.component.ts — self-cleaning subscription
import { DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

export class UserPanelComponent implements OnInit {
  // grabs the current injection context's DestroyRef
  private destroyRef = inject(DestroyRef);

  constructor(private userSvc: UserService) {}

  ngOnInit(): void {
    this.userSvc.userUpdates$
      .pipe(takeUntilDestroyed(this.destroyRef)) // auto-completes
      .subscribe(user => {
        this.name = user.name; // closure released on destroy
      });
  }
}

Fixed with the async pipe: removes the manual subscription entirely for template-only bindings, letting Angular own the whole lifecycle.

<!-- user-panel.component.html -->
<!-- async pipe subscribes on render, unsubscribes on destroy -->
<p>{{ (userSvc.userUpdates$ | async)?.name }}</p>

Verification & Regression Prevention

Target zero net growth in retained component instances across ten consecutive route visits, measured via DevTools → Memory → Heap Snapshot → Comparison view filtered by class name — a healthy fix shows # New and # Deleted matching for every visit, with Size Delta returning to within 1–2 KB of baseline. For Node-based end-to-end checks, run the app under node --expose-gc --max-old-space-size=2048 in a headless harness so global.gc() can be called between navigations before sampling process.memoryUsage().heapUsed; a leak-free component should show heapUsed flat within roughly 200 KB across ten cycles.

Add an ESLint rule to catch regressions before they ship: enable @angular-eslint/no-lifecycle-call alongside a custom rule (or the community rxjs-angular/prefer-takeuntil) that flags any .subscribe( call inside a component class not preceded by takeUntilDestroyed( or takeUntil( in the same pipe chain. Wire this into CI as a blocking lint step, and add a Playwright or Cypress smoke test that navigates through the route ten times and asserts the JS heap size (via performance.memory.usedJSHeapSize in a Chromium-only CI job) does not grow past a fixed threshold, for example 5 MB above baseline.

Frequently Asked Questions

Does the async pipe always eliminate the need to unsubscribe?

Only for the specific subscription it creates when bound in the template. If the same component also subscribes manually inside ngOnInit or a method to run side effects, those subscriptions still need takeUntilDestroyed or explicit unsubscribe logic — the async pipe only manages the stream it directly owns.

Should I use takeUntil(destroy$) or takeUntilDestroyed()?

Prefer takeUntilDestroyed() in Angular 16 and later — it reads the DestroyRef automatically and removes the boilerplate Subject, next(), and complete() calls that takeUntil(destroy$) requires. Keep takeUntil(destroy$) only for codebases pinned to older Angular versions or for non-component injectables where DestroyRef isn’t available in the constructor context.

Why does a heap snapshot still show my component after navigating away?

An active RxJS subscription holds a reference to the callback passed to subscribe(), and that callback closure captures this, the component instance. As long as the source observable is still emitting — a WebSocket, an interval, or a long-lived service Subject — the subscription stays alive, and with it the entire component tree it was rendered from.