Fix: Angular RxJS Memory Leak — Subscriptions Not Unsubscribed
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 leakOr 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()inngOnDestroy. Without it, the Subject stays open and doesn’t release its memory. Always call bothnext()andcomplete().
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 anEmptyErrorif 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:
- Open Chrome DevTools, go to the Memory tab.
- Take a Heap snapshot (baseline).
- Navigate to the component, interact, navigate away, repeat 5 times.
- Force a garbage collection (trash can icon in DevTools).
- Take another Heap snapshot.
- In the snapshot, filter by “Objects allocated between snapshots”.
- Look for component class instances — if they still exist after navigation, they’re leaked.
Profile memory growth:
- Go to Performance, then start recording.
- Navigate through routes several times.
- Stop recording.
- 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 subscription — Router.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 silently — this.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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: RxJS Not Working — Observable Not Emitting, Memory Leak from Unsubscribed Stream, or Operator Behaving Unexpectedly
How to fix RxJS issues — subscription management, switchMap vs mergeMap vs concatMap, error handling with catchError, Subject types, cold vs hot observables, and Angular async pipe.
Fix: Angular Form Validation Not Working — Validators Not Triggering
How to fix Angular form validation not working — Reactive Forms vs Template-Driven, custom validators, async validators, touched/dirty state, and error message display.
Fix: Angular Lazy Loading Not Working — Routes Not Code-Split
How to fix Angular lazy loading not working — loadChildren syntax, standalone components, route configuration mistakes, preloading strategies, and debugging bundle splits.
Fix: Angular Change Detection Not Working — View Not Updating
How to fix Angular change detection issues — OnPush strategy not triggering, async pipe, markForCheck vs detectChanges, zone.js and zoneless patterns, and manual change detection triggers.