Angular RxJS Subscription Memory Leaks

Angular’s tight coupling with RxJS makes Observables the default transport for HTTP calls, form values, router events, and store state — but every subscribe() you call by hand creates a Subscriber object that Angular will never clean up for you. When a component is destroyed but its subscriptions keep running, the Subscriber retains the component instance, its template, and the DOM subtree it rendered, so the heap climbs with every route change. This guide sits under Framework-Specific Memory Optimization and explains the V8 retention mechanics, a DevTools workflow to catch surviving subscribers, and the takeUntil, async-pipe, and takeUntilDestroyed patterns that fix them. For the step-by-step teardown recipes, see unsubscribing observables; for the Zone.js side of the problem, the Zone.js change detection overhead guide goes deeper.


Conceptual Grounding

When you call observable.subscribe(cb), RxJS constructs a SafeSubscriber and registers it with the source. For a cold, finite source such as HttpClient.get, the source completes after one emission and the subscriber tears itself down. The danger is endless sources — interval, fromEvent, router NavigationEnd events, Store.select, and long-lived Subjects in singleton services. These never complete, so the Subscriber stays registered on the source’s observer list for the lifetime of the source.

It is worth being precise about why Angular cannot solve this for you. The framework owns the objects it creates — component views, the async pipe’s subscription, template event listeners — and tears them down deterministically. A subscription you create with a bare .subscribe() is invisible to that machinery: Angular holds no reference to the returned Subscription, so ngOnDestroy has nothing to cancel. The contract is explicit — whoever calls subscribe owns the teardown. Every pattern in this guide is really one of two strategies: hand ownership back to Angular (the async pipe, template bindings) or bind teardown to a lifecycle signal the framework already emits (DestroyRef, a destroy$ Subject fired in ngOnDestroy).

Here is why that leaks. Your callback is a closure that captures this — the component instance. The endless source (often a @Injectable({ providedIn: 'root' }) singleton) holds the subscriber, the subscriber holds the callback, and the callback holds the component. Angular’s ngOnDestroy removes the component from the view tree and nulls its host bindings, but it does not walk your manual subscriptions. So in a post-GC heap snapshot you see a Subscriber with a small shallow size anchoring a retained graph of tens of KB to several MB: the component, its LView, and every detached DOM node it created. This is the same closure-retention mechanism described in closure memory leaks, specialised to Angular’s dependency-injection lifetimes.

Zone.js compounds it. Zone monkey-patches setInterval, setTimeout, and addEventListener so change detection can react to async work. A pending patched task sits in the Zone’s task queue holding a closure over your handler — which captures the component. Even with zero RxJS subscriptions, an uncleared setInterval keeps a destroyed component reachable from a GC root. The diagram below traces both retention paths from GC root to the destroyed component’s DOM.

There is a second, subtler retention channel worth naming: change detection state. Angular stores each component view as an LView array, and every template binding, @ViewChild reference, and ExpressionChangedAfterItHasBeenChecked guard is anchored from that structure. When a subscription keeps the component alive, it also keeps the whole LView alive — including cached DOM element references and the component’s __ngContext__ back-pointer. That is why a single leaked Subscriber with an 80-byte shallow size can show a retained size measured in MB: the retained graph is the component’s entire rendered subtree, not just the callback. In DevTools the tell is a large gap between Shallow Size and Retained Size on the SafeSubscriber row, exactly the signature you learn to read when interpreting heap snapshots.

Angular Subscription and Zone Task Retention Graph Diagram showing two retaining paths to a destroyed component: a root-provided service holds a SafeSubscriber whose callback captures the component, and a Zone.js timer task holds a closure capturing the same component, keeping its detached DOM subtree alive after ngOnDestroy. GC Root · root injector DataService (providedIn root) GC Root · NgZone macroTask queue observers[ ] SafeSubscriber shallow: ~80 bytes pending task ZoneTask (setInterval) holds handler closure captures this captures this ChartComponent (destroyed) ngOnDestroy ran — still reachable retained: ~1.4 MB — LEAK component.__ngContext__ LView + detached DOM canvas, child nodes, listeners

Diagnostic Workflow

Follow these steps to confirm a subscription leak rather than guessing at teardown. The goal is evidence: a countable delta of Subscriber and component instances that survive a mount/destroy cycle and a forced garbage collection. Guessing which stream leaks and sprinkling unsubscribe() calls tends to hide the symptom without proving the cause; the snapshot comparison below gives you a number you can watch fall to zero. Each step names the exact DevTools path or API and the output you should expect.

  1. Baseline after destroy. Navigate to the suspect route, then navigate away so ngOnDestroy fires. Open DevTools → Memory → Heap Snapshot and capture. Expected: a snapshot of ~10–40 MB with the component already logically dead.

  2. Multiply the leak. Route into and out of the component 5–10 times. Repetition turns a single retained instance into a countable stack, which is far easier to see than one stray object. Expected: if leaking, retained size climbs a few hundred KB to several MB per cycle.

  3. Force garbage collection. In DevTools → Memory, click the trash-can (Collect garbage) icon so anything genuinely unreachable is swept before you measure. Expected: memory drops, but leaked instances survive.

  4. Take a comparison snapshot. Capture a second Heap Snapshot, then set the view dropdown to Comparison against the baseline. Type Subscriber into the Class filter. Expected: a non-zero #Delta for SafeSubscriber equal to the number of mount/destroy cycles proves subscribers are surviving.

  5. Filter for the component class. Change the filter to your component name, e.g. ChartComponent. Angular keeps destroyed instances out of the tree, so any instance here is retained. Expected: one live instance per cycle.

  6. Trace the retainer. Select a surviving instance and read the Retainers pane at the bottom. Follow the path upward. Expected: it terminates at a root service’s observers array, an NgZone task, or a router event subject.

  7. Cross-check Zone tasks. In the Console run the snippet below to count pending macrotasks, then apply your fix and re-run. Use --js-flags="--expose-gc" on Chrome launch if you want to call window.gc() from scripts. Expected: pending count returns to its idle baseline.

  8. Confirm in production build. Leaks that vanish in ng serve can persist in a production bundle where enableProdMode disables the debug tooling. Reproduce against ng build --configuration production served locally, and drive the mount/destroy cycle with the same snapshot comparison. Expected: the Subscriber delta behaves identically, ruling out a dev-only artefact.

This counts the timers and intervals Zone.js is still tracking so you can spot handlers that outlive a component.

// Read NgZone internals via the root component's injector.
// Run in DevTools Console while the leaking route is closed.
const el = document.querySelector('app-root'); // root host
const inj = window.ng.getInjector(el);         // ng debug API
const NgZone = window.ng.coreTokens.NgZone;    // NgZone token
const ngZone = inj.get(NgZone);                // resolve zone
// true while any setInterval/timer task is pending:
console.log('pending macrotasks:', ngZone.hasPendingMacrotasks);

Code Patterns & Signatures

The safest pattern is to never hold a subscription at all — bind the Observable in the template and let Angular’s AsyncPipe own the lifecycle. This example streams a store selector straight into the view with no manual subscribe().

// AsyncPipe subscribes on view create and unsubscribes
// in its own ngOnDestroy when the host view is torn down.
@Component({
  selector: 'app-price',
  standalone: true,
  imports: [AsyncPipe],
  template: `
    <!-- async pipe owns the subscription lifecycle -->
    <span>{{ price$ | async }}</span>
  `,
})
export class PriceComponent {
  // Endless selector stream; never call .subscribe() here:
  price$ = this.store.select(selectPrice);
  constructor(private store: Store) {}
}

The async pipe wins because Angular, not your code, holds the only subscription reference, and Angular destroys it deterministically inside the pipe’s own teardown. It also runs the stream through markForCheck, so it composes cleanly with OnPush change detection. Prefer it for any value that is read straight into the template.

When you genuinely need imperative side effects — logging, imperative DOM work, WebSocket sends — use takeUntilDestroyed from @angular/core/rxjs-interop. Called in an injection context it wires teardown to the component’s DestroyRef automatically.

// takeUntilDestroyed reads the current DestroyRef and
// completes the stream when the component is destroyed.
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';

@Component({ /* ... */ })
export class TickerComponent {
  constructor() {
    // Called in the constructor = valid injection context:
    interval(1000)                    // endless source
      .pipe(takeUntilDestroyed())     // auto-teardown on destroy
      .subscribe(n => this.render(n));// side effect is safe now
  }
  render(_n: number) { /* ... */ }
}

Outside an injection context — for example subscribing inside ngOnInit after async setup — capture the DestroyRef explicitly and pass it in.

// Capture DestroyRef during construction, then reuse it
// later when the injection context is no longer active.
import { DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

export class FeedComponent implements OnInit {
  private destroyRef = inject(DestroyRef); // captured early
  ngOnInit() {
    this.feed.stream$
      // pass the saved ref so teardown still binds correctly:
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(msg => this.append(msg));
  }
  append(_m: unknown) { /* ... */ }
}

The DestroyRef.onDestroy callback registered by takeUntilDestroyed is invoked exactly once, during the component’s destruction, and errors thrown inside it are isolated per callback — so one failing teardown will not block the others. That determinism is why it has replaced the manual Subject pattern as the default recommendation from Angular 16 onward. It also works for directives, pipes, and injectables that share a component’s lifetime, since each has its own DestroyRef in the injector hierarchy.

For codebases still on Angular 15 or earlier, the classic pattern is a destroy$ Subject completed in ngOnDestroy. Emit once and complete so every piped stream terminates.

// Pre-DestroyRef pattern: one Subject unsubscribes all
// streams when ngOnDestroy emits and completes it.
export class LegacyComponent implements OnDestroy {
  private destroy$ = new Subject<void>(); // teardown signal
  ngOnInit() {
    fromEvent(window, 'resize')            // endless source
      .pipe(takeUntil(this.destroy$))      // stop on destroy$
      .subscribe(() => this.layout());
  }
  ngOnDestroy() {
    this.destroy$.next();  // trigger takeUntil completion
    this.destroy$.complete(); // release the Subject itself
  }
  layout() { /* ... */ }
}

Symptom-to-Fix Reference

Use the following matrix as a fast lookup once a snapshot or a rising performance.memory.usedJSHeapSize reading tells you something is retained. Each row pairs the observable symptom with the underlying RxJS or Zone.js cause, the smallest fix that resolves it, and the metric you should watch to confirm the fix landed. Verify every “Measurable Impact” with the comparison snapshot from the workflow above rather than trusting the code change alone.

Symptom Root Cause Immediate Action Measurable Impact
Heap grows each route change Manual subscribe, no teardown Add takeUntilDestroyed Subscriber delta → 0
Duplicate HTTP calls after nav Re-subscribe on re-init Switch to async pipe 1 request per view
Detached DOM after destroy Component held by subscriber Trace retainer, add teardown ~1.4 MB freed/cycle
Timers fire post-destroy Uncleared setInterval clearInterval in destroy Pending macrotasks → 0
Store selector piles up Store.select never completes async pipe or takeUntil Flat retained size
Slow CD after many mounts Zone tasks retained Remove listeners on destroy Fewer CD frames
Subscriber count = mount count Missing unsubscribe Complete destroy$ Subject Instances collected

Edge Cases & Gotchas

takeUntil operator ordering. Place takeUntil (or takeUntilDestroyed) last in the pipe() chain. If another operator such as combineLatest, switchMap, or shareReplay sits after it, that operator can resubscribe to an inner source and re-open the stream after your teardown signal fired — silently re-leaking. Rule: takeUntil is always the final operator before subscribe.

shareReplay without refCount. shareReplay({ bufferSize: 1, refCount: false }) keeps its internal ReplaySubject and source subscription alive forever, even after every consumer unsubscribes. In a fix, use shareReplay({ bufferSize: 1, refCount: true }) so the multicast tears down when the last subscriber leaves, or share() for hot streams.

Nested subscribes. Calling .subscribe() inside another .subscribe() callback creates an inner subscriber that takeUntilDestroyed on the outer stream never touches. Flatten with switchMap, mergeMap, or concatMap so the single outer pipe governs teardown of the whole chain.

Long-lived service subscriptions. A @Injectable({ providedIn: 'root' }) service subscribing to another endless source lives for the whole app, so DestroyRef teardown never triggers — the service is never destroyed. If the service must subscribe, expose the stream and let components consume it with the async pipe instead, or gate the subscription behind explicit start()/stop() methods.

AsyncPipe with multiple bindings. Referencing the same obs$ | async in several template spots creates one subscription each. For endless or expensive sources this multiplies subscribers and HTTP calls. Bind once with *ngIf="obs$ | async as value" and reuse value in the block.

Route-scoped services outliving the route. A service provided at a lazy-loaded route level is destroyed when that route’s injector is torn down, but a service subscribing to Router.events still sees every navigation for the whole session. If such a service caches per-route state keyed by URL, that map grows unbounded across a long session. Clear entries on NavigationEnd for routes you have left, or scope the cache to the component with providers: [] so it dies with the view.

EventEmitter and @Output subscriptions. Angular @Output properties are EventEmitters, which are Subjects under the hood. A parent that subscribes to a child’s output imperatively — rather than via (event) template binding — owns that subscription and must tear it down. Prefer the template (childEvent)="..." syntax, which Angular unsubscribes automatically when the parent view is destroyed.


Frequently Asked Questions

Does the async pipe unsubscribe automatically?

Yes. Angular’s AsyncPipe subscribes when the template binding is created and calls unsubscribe in its own ngOnDestroy, which runs when the host view is torn down. This makes it the safest default for template-driven streams because there is no manual subscription reference to forget.

Why does takeUntilDestroyed throw an error in my service?

Called with no argument, takeUntilDestroyed reads the current injection context to find the DestroyRef, which only exists during construction of a component, directive, pipe, or injectable. If you call it later, or outside an injection context, pass an explicit DestroyRef captured via inject(DestroyRef) in the constructor.

Do I still need takeUntil if the Observable completes on its own?

No. A finite Observable such as HttpClient.get emits once and completes, which tears down its own Subscriber. Explicit teardown matters for endless streams — interval, fromEvent, WebSocket subjects, router events, and store selectors — that never complete and therefore retain their subscribers indefinitely.

Can Zone.js keep a component alive after ngOnDestroy?

Yes. Zone.js monkey-patches setInterval, addEventListener, and similar APIs. A pending patched task holds a closure over your callback, which captures the component instance. Until that timer is cleared or the listener removed, the component and its subtree stay reachable from the Zone task queue regardless of Angular’s lifecycle.