Skip to content

Fix: Angular RxJS Memory Leak — Subscriptions Not Unsubscribed

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix RxJS memory leaks in Angular — unsubscribing from Observables, takeUntilDestroyed, async pipe, subscription management patterns, and detecting leaks with Chrome DevTools.

The Problem

An Angular application’s memory usage grows over time as users navigate between routes:

Initial memory: 15 MB
After 10 route changes: 42 MB
After 50 route changes: 180 MB  ← Memory leak

Or a component continues to receive and process events after it’s been destroyed:

// UserComponent logs data even after navigating away
ngOnInit() {
  this.userService.userUpdates$.subscribe(user => {
    console.log('User updated:', user);
    // Still fires after component is destroyed — memory leak
  });
}

Or duplicate event handlers accumulate, causing a function to execute multiple times per event:

// Dashboard showing data 3x because 3 subscriptions accumulated
// (created on each navigation to the dashboard without cleanup)

Why This Happens

RxJS Observables are lazy — they run as long as they have subscribers. When a component subscribes to an Observable but doesn’t unsubscribe when the component is destroyed, the Observable continues:

  • Holding references — the subscription holds a reference to the component’s callback. The component can’t be garbage collected even after Angular destroys it.
  • Processing data — the callback runs for every emission, updating properties on a destroyed component (may trigger errors or silently corrupt application state).
  • Accumulating subscriptions — each time the component is created (route navigation), a new subscription is added. Previous subscriptions aren’t cleaned up.

The confusion usually starts because not all Observables leak. Short-lived Observables that complete after emitting — HttpClient.get(), of(), from() — clean themselves up. They complete, the subscription ends, and memory is freed. The problem is with long-lived Observables that never complete on their own: interval(), Router.events, Store.select() (NgRx), WebSocket streams, Subject instances that live in a service, and fromEvent() on DOM elements. These keep emitting indefinitely, so the subscription (and the reference to the destroyed component) persists until the browser tab closes.

A subtlety that makes this harder to notice in development: the leak is proportional to how many times the component is created and destroyed. If you only navigate once during manual testing, you see one orphaned subscription — a few KB at most. In production, users navigate dozens of times per session, and each navigation adds another subscription that never gets cleaned up. The memory footprint grows linearly.

Diagnostic Timeline

When you suspect a memory leak but aren’t sure which Observable is causing it, follow this step-by-step process using Chrome DevTools.

Minute 0 — Establish a baseline heap snapshot. Open Chrome DevTools, go to the Memory tab, and take a Heap Snapshot. Note the total heap size. This is your baseline before any navigation.

Minute 1 — Reproduce the leak. Navigate to the suspected component, interact with it (trigger any subscriptions), then navigate away. Repeat this cycle 5 times. Each cycle should create and destroy the component.

Minute 2 — Force garbage collection and take a second snapshot. Click the trash can icon in DevTools to force GC. Take another Heap Snapshot. Compare the total heap size to the baseline. If it grew significantly (not just a few KB), you have a leak.

Minute 3 — Identify detached DOM nodes. In the second snapshot, type “Detached” in the filter box. Detached DOM nodes are DOM elements that Angular removed from the page but that can’t be garbage collected because something still references them. Each detached node points back to the component that created it — and that component is being kept alive by an active subscription.

Minute 4 — Trace the retaining path. Select a detached DOM node and look at the Retainers panel at the bottom. Follow the chain of references. You’ll typically see something like: DetachedHTMLDivElement → UserComponent → Subscriber → SafeSubscriber → Observable. The Subscriber in that chain is the smoking gun — it’s the subscription that was never unsubscribed.

Minute 5 — Identify the Observable source. Click the Observable in the retaining path. The constructor or source property tells you which Observable created it. Common culprits: interval(5000) for polling, this.router.events for navigation tracking, this.store.select(...) for NgRx selectors, or a Subject in a shared service.

Minute 6 — Decide the fix. Now that you know which Observable keeps the subscription alive, pick the right cleanup strategy:

  • Template-only usage → async pipe (Fix 1)
  • Imperative subscription in Angular 16+ → takeUntilDestroyed (Fix 2)
  • Imperative subscription in older Angular → manual unsubscribe or takeUntil (Fix 3/4)
  • One-time read → take(1) or first() (Fix 5)

Fix 1: Use the async Pipe (Simplest Solution)

The Angular async pipe automatically subscribes when the component renders and unsubscribes when the component is destroyed — no manual cleanup needed:

// BEFORE — manual subscription (leaks)
@Component({ template: `<p>{{ userName }}</p>` })
export class UserComponent implements OnInit {
  userName = '';

  constructor(private userService: UserService) {}

  ngOnInit() {
    this.userService.currentUser$.subscribe(user => {
      this.userName = user.name;  // Runs after component is destroyed
    });
  }
}
// AFTER — async pipe handles subscription lifecycle
@Component({
  template: `<p>{{ (user$ | async)?.name }}</p>`,
})
export class UserComponent {
  user$ = this.userService.currentUser$;

  constructor(private userService: UserService) {}
  // No ngOnInit, no subscription, no cleanup needed
}

Async pipe with *ngIf to avoid multiple subscriptions:

<!-- BAD — subscribes 3 times to user$ -->
<p>{{ (user$ | async)?.name }}</p>
<p>{{ (user$ | async)?.email }}</p>
<img [src]="(user$ | async)?.avatar" />

<!-- GOOD — subscribe once, use the value multiple times -->
<ng-container *ngIf="user$ | async as user">
  <p>{{ user.name }}</p>
  <p>{{ user.email }}</p>
  <img [src]="user.avatar" />
</ng-container>

Angular 17+ with @if (no NgIf needed):

@if (user$ | async; as user) {
  <p>{{ user.name }}</p>
  <p>{{ user.email }}</p>
}

Fix 2: Use takeUntilDestroyed (Angular 16+)

takeUntilDestroyed is the modern Angular approach for inline subscriptions that need cleanup:

import { Component, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

@Component({ ... })
export class UserComponent implements OnInit {
  constructor(
    private userService: UserService,
    private destroyRef = inject(DestroyRef),  // Injection in constructor
  ) {}

  ngOnInit() {
    this.userService.userUpdates$
      .pipe(takeUntilDestroyed(this.destroyRef))  // Auto-unsubscribes on destroy
      .subscribe(user => {
        this.processUser(user);
      });
  }
}

In the constructor — even simpler when takeUntilDestroyed is called without arguments:

import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

@Component({ ... })
export class UserComponent {
  private userService = inject(UserService);

  // Called in constructor — no argument needed, uses current injection context
  constructor() {
    this.userService.userUpdates$
      .pipe(takeUntilDestroyed())  // No DestroyRef argument needed in constructor
      .subscribe(user => {
        this.processUser(user);
      });
  }
}

takeUntilDestroyed() without arguments only works in an injection context (constructor or field initializer). For ngOnInit, pass inject(DestroyRef) explicitly.

Fix 3: Manual Unsubscription with ngOnDestroy

For Angular versions before 16, the classic approach — unsubscribe in ngOnDestroy:

import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';

@Component({ ... })
export class UserComponent implements OnInit, OnDestroy {
  private subscription = new Subscription();

  ngOnInit() {
    // Add each subscription to the composite Subscription
    this.subscription.add(
      this.userService.userUpdates$.subscribe(user => this.processUser(user))
    );

    this.subscription.add(
      this.router.events.subscribe(event => this.handleNavigation(event))
    );

    this.subscription.add(
      interval(5000).subscribe(() => this.refresh())
    );
  }

  ngOnDestroy() {
    // Unsubscribes all added subscriptions at once
    this.subscription.unsubscribe();
  }
}

Subscription.add() composite pattern is cleaner than tracking individual subscriptions in an array:

// Less clean — array approach
private subs: Subscription[] = [];

ngOnInit() {
  this.subs.push(this.service.data$.subscribe(...));
  this.subs.push(this.router.events.subscribe(...));
}

ngOnDestroy() {
  this.subs.forEach(s => s.unsubscribe());
}

// Cleaner — composite Subscription
private subscription = new Subscription();

ngOnInit() {
  this.subscription.add(this.service.data$.subscribe(...));
  this.subscription.add(this.router.events.subscribe(...));
}

ngOnDestroy() {
  this.subscription.unsubscribe();  // One call
}

Fix 4: Use Subject + takeUntil (Pre-Angular 16 Pattern)

The takeUntil pattern uses a Subject that emits when the component is destroyed:

import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

@Component({ ... })
export class UserComponent implements OnInit, OnDestroy {
  private destroy$ = new Subject<void>();

  ngOnInit() {
    this.userService.userUpdates$
      .pipe(takeUntil(this.destroy$))
      .subscribe(user => this.processUser(user));

    this.router.events
      .pipe(takeUntil(this.destroy$))
      .subscribe(event => this.handleNavigation(event));
  }

  ngOnDestroy() {
    this.destroy$.next();   // Emits — all takeUntil operators complete
    this.destroy$.complete();
  }
}

Common Mistake: Forgetting this.destroy$.complete() in ngOnDestroy. Without it, the Subject stays open and doesn’t release its memory. Always call both next() and complete().

Fix 5: Use take(1) and first() for One-Time Subscriptions

When you only need one emission from an Observable, auto-complete it:

import { take, first } from 'rxjs/operators';

// take(1) — completes after 1 emission, unsubscribes automatically
this.userService.currentUser$.pipe(
  take(1)
).subscribe(user => {
  this.initializeForm(user);
  // No need to store or unsubscribe — completes after first emission
});

// first() — completes after first emission that passes the predicate
this.router.events.pipe(
  first(event => event instanceof NavigationEnd)
).subscribe(event => {
  this.trackPageView();
  // Completes after the first NavigationEnd
});

first() vs take(1):

  • take(1) completes after exactly 1 emission, even if the Observable never emits (no error)
  • first() completes after 1 emission but throws an EmptyError if the Observable completes without emitting

Fix 6: Service-Level Subscription Management

Subscriptions in services (not components) don’t benefit from component lifecycle hooks. Services are singletons — they live for the entire app lifetime. Be careful with:

@Injectable({ providedIn: 'root' })
export class NotificationService {
  private socket: WebSocket;

  // DON'T — no cleanup mechanism
  startListening() {
    fromEvent(this.socket, 'message').subscribe(msg => {
      this.processMessage(msg);
    });
  }

  // DO — store subscription and expose cleanup
  private wsSubscription?: Subscription;

  startListening() {
    this.wsSubscription = fromEvent(this.socket, 'message').subscribe(msg => {
      this.processMessage(msg);
    });
  }

  stopListening() {
    this.wsSubscription?.unsubscribe();
  }
}

For route-scoped services — provide the service at the component level so it’s destroyed with the component:

@Component({
  providers: [DataService],  // New instance per component, destroyed with component
})
export class MyComponent {
  constructor(private dataService: DataService) {}
}

Fix 7: Detect Memory Leaks with Chrome DevTools

Step-by-step leak detection:

  1. Open Chrome DevTools, go to the Memory tab.
  2. Take a Heap snapshot (baseline).
  3. Navigate to the component, interact, navigate away, repeat 5 times.
  4. Force a garbage collection (trash can icon in DevTools).
  5. Take another Heap snapshot.
  6. In the snapshot, filter by “Objects allocated between snapshots”.
  7. Look for component class instances — if they still exist after navigation, they’re leaked.

Profile memory growth:

  1. Go to Performance, then start recording.
  2. Navigate through routes several times.
  3. Stop recording.
  4. Check the JS Heap line — a sawtooth pattern (up on navigation, down on GC) is healthy. Steady upward growth indicates a leak.

Angular DevTools — check for component instances that persist after navigation in the Component Explorer.

Detect leaked subscriptions at runtime:

// Add to a base component class for debugging
export abstract class LeakDetectingComponent implements OnDestroy {
  private static instanceCount = new Map<string, number>();
  private readonly className = this.constructor.name;

  constructor() {
    const count = (LeakDetectingComponent.instanceCount.get(this.className) ?? 0) + 1;
    LeakDetectingComponent.instanceCount.set(this.className, count);
    if (count > 1) {
      console.warn(`${this.className}: ${count} instances exist — possible leak`);
    }
  }

  ngOnDestroy() {
    const count = LeakDetectingComponent.instanceCount.get(this.className)! - 1;
    LeakDetectingComponent.instanceCount.set(this.className, count);
  }
}

Still Not Working?

async pipe still leaking — the async pipe unsubscribes when the component is destroyed, but if the template is inside a structural directive that hides (not destroys) the component, the Observable stays subscribed:

<!-- ngIf DESTROYS the component — async pipe unsubscribes -->
<app-user *ngIf="showUser"></app-user>

<!-- [hidden] HIDES the component — component stays alive -->
<app-user [hidden]="!showUser"></app-user>
<!-- async pipe unsubscribes when showUser becomes false here too, since the component persists -->

Router events subscriptionRouter.events is a hot Observable that never completes. Always use takeUntilDestroyed or take(1) when subscribing:

// Leaks without cleanup
this.router.events.subscribe(event => { ... });

// Safe — with takeUntilDestroyed
this.router.events.pipe(
  takeUntilDestroyed(this.destroyRef),
  filter(e => e instanceof NavigationEnd),
).subscribe(event => { ... });

interval() and timer() never complete — these must always be combined with takeUntilDestroyed, take(n), or stored for manual unsubscription.

NgRx Store selects leak silentlythis.store.select(selectUser) returns an Observable that never completes. Unlike HttpClient.get(), it stays open for the lifetime of the store (i.e., the entire app). Always use the async pipe or takeUntilDestroyed for store selects:

// Leaks — store.select never completes
ngOnInit() {
  this.store.select(selectUser).subscribe(user => {
    this.user = user;
  });
}

// Safe — takeUntilDestroyed handles cleanup
ngOnInit() {
  this.store.select(selectUser)
    .pipe(takeUntilDestroyed(this.destroyRef))
    .subscribe(user => {
      this.user = user;
    });
}

WebSocket or SSE streams in a service — if a service opens a WebSocket connection and exposes it as an Observable, every component that subscribes adds a listener to the same stream. The service doesn’t know when components are destroyed. Either use takeUntilDestroyed on the component side, or provide the service at the component level (not providedIn: 'root') so it’s destroyed with the component.

The subscription count keeps growing even with takeUntil — verify that takeUntil(this.destroy$) is the last operator in the pipe. If you place it before switchMap or mergeMap, inner Observables created by those operators are not covered by the takeUntil and continue emitting:

// WRONG — switchMap creates inner subscription AFTER takeUntil
this.trigger$.pipe(
  takeUntil(this.destroy$),
  switchMap(() => this.longRunning$),  // Inner observable not cleaned up
).subscribe();

// CORRECT — takeUntil is last
this.trigger$.pipe(
  switchMap(() => this.longRunning$),
  takeUntil(this.destroy$),  // Covers both outer and inner
).subscribe();

For related Angular issues, see Fix: Angular Change Detection Not Working, Fix: Angular Lazy Loading Not Working, Fix: Angular HTTP Interceptor Not Working, and Fix: Go Goroutine Leak.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles