Fix: Angular RxJS Memory Leak — Subscriptions Not Unsubscribed
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.
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()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 → 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 → 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.
For related Angular issues, see Fix: Angular Change Detection Not Working and Fix: Angular Lazy Loading Not Working.
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 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 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 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.