Unsubscribing Observables to Prevent Angular Leaks
A component subscribes to an observable in ngOnInit, never calls
unsubscribe() in ngOnDestroy, and the router moves the user to a
new page — but the component instance, its template closure, and
everything it captured stay resident on the heap because the source
observable is still emitting. This sits inside Angular RxJS subscription leaks,
part of the broader Framework-Specific Memory Optimization section,
and it is the single most common leak pattern reported in Angular
applications with long-lived services or WebSocket streams.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
| Heap grows per route visit | Subscription outlives component | Add teardown in ngOnDestroy |
| Old component in snapshot | Closure keeps this alive |
Use takeUntilDestroyed() |
| Callback fires post-navigation | Source observable never completes | Switch to async pipe in template |
| Duplicate handlers stack up | Re-subscribe without cleanup first | Consolidate with Subscription.add |
Root Cause
RxJS subscriptions are plain JavaScript objects that hold a reference
to the observer function you passed to subscribe(). When that
observer is an arrow function or a bound method inside a component
class, its closure captures this — the component instance itself,
along with every property, injected service, and DOM reference it
holds. Angular’s change detection and the router do not know anything
about RxJS internals: destroying a component via ngOnDestroy only
runs the code you write there. If you never call unsubscribe(), the
subscription remains registered on the source observable’s internal
list of observers, and that list is reachable from the observable
itself, which is very often a singleton service that lives for the
whole application lifetime.
This is structurally the same problem covered in closure memory leaks:
a long-lived object (here, a service-scoped Subject or a WebSocket
wrapper) holds a reference to a short-lived one (the component) purely
because a callback closure was never released. The garbage collector
cannot reclaim the component because a live reference chain exists —
this has nothing to do with how mark-and-sweep collection works
failing; the collector is behaving correctly by keeping alive
everything that is still reachable. The bug is that the reachability
graph itself is wrong.
Angular’s zone.js compounds the effect: every emission from a
retained subscription re-enters the Angular zone, which can trigger a
change-detection pass against a destroyed component’s view, doubling
the CPU cost on top of the memory cost. Interactions between
subscription leaks and zone overhead are covered separately in Zone.js change detection memory overhead.
Step-by-Step Fix
-
Confirm the leak with a heap snapshot diff. Open
DevTools → Memory → Heap Snapshot, take a baseline snapshot on the component’s route, navigate away, force a collection with the trash-can icon, then take a second snapshot and switch toComparisonview. Filter by your component’s class name. Expected output: a positive# Newcount for the component class after every visit — for example+1per route change, confirming it is never freed. -
Trace the retainer chain. In the comparison view, click the retained instance and expand
Retainers. Look for a path throughSubscriber→destination→ your component’s zone-patched method. Verification checkpoint: the retainer path should show an RxJSSubscriberobject referencing the component, not the reverse — this confirms the observable, not the component, is the anchor keeping it alive. -
Convert template-only subscriptions to the async pipe. If the value is only read in the template, remove the manual
subscribe()call entirely and bind the observable with| async. Expected output: the component class no longer contains aSubscriptionfield for that stream; Angular’sAsyncPipesubscribes onngOnInit-equivalent initialization and unsubscribes in its ownngOnDestroy. -
Add
takeUntilDestroyed()for imperative subscriptions. For subscriptions used to run side effects in class code, injectDestroyRefand pipe every stream throughtakeUntilDestroyed(destroyRef)before callingsubscribe(). Verification checkpoint: re-run the heap snapshot diff from step 1 — the# Newcount for the component should drop to0after the fix. -
Consolidate legacy subscriptions with
Subscription.add. In codebases on Angular versions before 16, create oneSubscriptioninstance per component,add()every subscription to it, and call.unsubscribe()once insidengOnDestroy. Expected output: a single teardown call replaces N individualunsubscribe()calls, removing the risk of forgetting one when a new subscription is added later.
Command & Code Reference
Broken component: a subscription started in ngOnInit with no
matching teardown, so the closure over this is retained by the
service’s internal subject for the application’s entire lifetime.
// user-panel.component.ts — leaks on every route visit
export class UserPanelComponent implements OnInit {
constructor(private userSvc: UserService) {}
ngOnInit(): void {
// subscribe captures `this` in the closure below
this.userSvc.userUpdates$.subscribe(user => {
this.name = user.name; // keeps component alive
});
// no ngOnDestroy — subscription is never released
}
}
Fixed with takeUntilDestroyed(): the injected DestroyRef completes
the piped observable automatically when Angular tears the component
down, so the subscriber list drops the reference on its own.
// user-panel.component.ts — self-cleaning subscription
import { DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class UserPanelComponent implements OnInit {
// grabs the current injection context's DestroyRef
private destroyRef = inject(DestroyRef);
constructor(private userSvc: UserService) {}
ngOnInit(): void {
this.userSvc.userUpdates$
.pipe(takeUntilDestroyed(this.destroyRef)) // auto-completes
.subscribe(user => {
this.name = user.name; // closure released on destroy
});
}
}
Fixed with the async pipe: removes the manual subscription entirely for template-only bindings, letting Angular own the whole lifecycle.
<!-- user-panel.component.html -->
<!-- async pipe subscribes on render, unsubscribes on destroy -->
<p>{{ (userSvc.userUpdates$ | async)?.name }}</p>
Verification & Regression Prevention
Target zero net growth in retained component instances across ten
consecutive route visits, measured via
DevTools → Memory → Heap Snapshot → Comparison view filtered by
class name — a healthy fix shows # New and # Deleted matching for
every visit, with Size Delta returning to within 1–2 KB of baseline.
For Node-based end-to-end checks, run the app under
node --expose-gc --max-old-space-size=2048 in a headless harness so
global.gc() can be called between navigations before sampling
process.memoryUsage().heapUsed; a leak-free component should show
heapUsed flat within roughly 200 KB across ten cycles.
Add an ESLint rule to catch regressions before they ship: enable
@angular-eslint/no-lifecycle-call alongside a custom rule (or the
community rxjs-angular/prefer-takeuntil) that flags any
.subscribe( call inside a component class not preceded by
takeUntilDestroyed( or takeUntil( in the same pipe chain. Wire
this into CI as a blocking lint step, and add a Playwright or
Cypress smoke test that navigates through the route ten times and
asserts the JS heap size (via
performance.memory.usedJSHeapSize in a Chromium-only CI job)
does not grow past a fixed threshold, for example 5 MB above
baseline.
Frequently Asked Questions
Does the async pipe always eliminate the need to unsubscribe?
Only for the specific subscription it creates when bound in the
template. If the same component also subscribes manually inside
ngOnInit or a method to run side effects, those subscriptions still
need takeUntilDestroyed or explicit unsubscribe logic — the async
pipe only manages the stream it directly owns.
Should I use takeUntil(destroy$) or takeUntilDestroyed()?
Prefer takeUntilDestroyed() in Angular 16 and later — it reads the
DestroyRef automatically and removes the boilerplate Subject,
next(), and complete() calls that takeUntil(destroy$) requires.
Keep takeUntil(destroy$) only for codebases pinned to older Angular
versions or for non-component injectables where DestroyRef isn’t
available in the constructor context.
Why does a heap snapshot still show my component after navigating away?
An active RxJS subscription holds a reference to the callback passed
to subscribe(), and that callback closure captures this, the
component instance. As long as the source observable is still
emitting — a WebSocket, an interval, or a long-lived service Subject —
the subscription stays alive, and with it the entire component tree
it was rendered from.
Related
- Angular RxJS subscription leaks — the parent guide covering the full subscription-leak pattern in Angular.
- Zone.js change detection memory overhead — how retained subscriptions compound with zone-driven change detection.
- Framework-Specific Memory Optimization — the main section comparing memory patterns across React, Vue, and Angular.
- Take and compare heap snapshots in Chrome — the step-by-step workflow used to confirm this leak.
- Closure memory leaks in modern JavaScript — the general closure-retention pattern this bug is an instance of.