Skip to content

Fix: Angular RxJS Memory Leak — Subscriptions Not Unsubscribed

FixDevs ·

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.

Long-lived Observables that cause leaks: HttpClient requests with interval/polling, Router.events, Store.select() (NgRx), WebSocket streams, Subject that lives in a service, fromEvent() on DOM elements.

Short-lived Observables that complete automatically and don’t need cleanup: HttpClient.get() (completes after one emission), of(), from().

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 → 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 → 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.

For related Angular issues, see Fix: Angular Change Detection Not Working and Fix: Angular Lazy Loading Not Working.

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