Angular Memory Leaks: Find and Fix Them With Heap Snapshots [2026]
A memory leak in a single-page app rarely crashes anything on the first visit. It shows up as a tab that gets slower the longer someone uses it: route changes lag, typing stutters, and after an hour of work the browser warns that the page is using too much memory. Because users never reload an SPA, every component that isn't cleaned up properly stays in memory until they do.
This is lesson 8.7 of the Angular Tutorial. Lesson 7.4 covered the cleanup APIs (DestroyRef, takeUntilDestroyed()); this lesson is about finding the leaks those APIs didn't prevent. You'll learn what a leak looks like in Angular, a heap-snapshot workflow that pinpoints the culprit, the leak patterns that survive modern cleanup, and how to automate leak checks so they don't come back.
What a leak is in an Angular app #
When a component is destroyed (for example, you navigate away), Angular removes its DOM and drops its references. The garbage collector then frees the component, its template's DOM nodes and everything they reference, unless something long-lived still points at them:
| Long-lived holder | Typical leak |
|---|---|
A root service (providedIn: 'root') |
A subscription, callback or cached object referencing the destroyed component |
window / document |
An event listener added with addEventListener and never removed |
| A timer or observer | setInterval, ResizeObserver, IntersectionObserver, a WebSocket handler |
| A third-party library instance | A chart, map or editor that was never destroy()ed |
| A module-level variable | An array or Map at file scope that collects component references |
One retained component usually drags its whole view with it: DOM nodes (now detached from the document), child components, and every object they hold. That's why leaks grow so quickly.
Symptoms worth investigating #
- The JS heap size keeps climbing as you navigate back and forth, and doesn't drop after a forced garbage collection.
- The same component's constructor or
ngOnInitruns more and more times for a single user action (duplicate subscriptions firing). - Route changes and typing get slower over a session, but a reload fixes it.
DevTools → Performance monitor (from the command menu) shows JS heap size and DOM node count live, which is a quick first check: navigate between two routes ten times and watch whether both numbers return to their starting level.
Which tool answers which question #
| Question | Tool | Where |
|---|---|---|
| Is memory growing at all? | Performance monitor (JS heap, DOM nodes) | DevTools → command menu → "Show Performance monitor" |
| Which objects leaked? | Heap snapshot, Comparison view | DevTools → Memory → Heap snapshot |
| Why is this object still alive? | Retainers panel of a selected object | Bottom of the heap snapshot view |
| Which DOM nodes were orphaned? | Heap snapshot filtered on Detached |
Class filter box in the snapshot |
| What allocates memory over time? | Allocation instrumentation on timeline | DevTools → Memory → Allocations on timeline |
| Does a leak come back after a release? | memlab scenario, cleanup unit tests | CI (see "Automate it" below) |
The heap-snapshot workflow #
This finds almost every leak, in about ten minutes:
- Open the page in a fresh tab (a production build, or a dev build without extensions), then DevTools → Memory → Heap snapshot → Take snapshot. This is your baseline.
- Repeat the suspect action several times, such as opening and closing a dialog, or navigating to the page and away. Doing it 5–10 times makes leaks stand out from noise.
- Return to the starting state, click the trash-can icon to force garbage collection, and take a second snapshot.
- In the second snapshot, choose Comparison view against the first. Sort by # Delta or Size Delta.
- Search for your component's class name (e.g.
OrderDetail). If you opened it five times and it shows five new instances after GC, all five leaked. The correct count is zero. - Also filter on
Detachedto find DOM nodes that are no longer in the document but still in memory. - Select a leaked instance and read the Retainers panel from top to bottom. It shows the reference chain keeping the object alive, ending at the long-lived holder. That holder is your bug.
Retainer paths look noisy at first. Skip internal entries and look for names you recognise: a service, a Subject, a listener array, a library object.
Leaks that survive takeUntilDestroyed() #
Lesson 7.4's patterns prevent the classic subscription leak. These are the ones that still get through:
1. shareReplay without refCount.
// Root service
readonly config$ = this.http.get<Config>('/api/config').pipe(
shareReplay(1), // keeps the source subscribed forever
);
For an HTTP call that completes, that's harmless. For an infinite source (a WebSocket, interval, a store selector), shareReplay(1) keeps the upstream subscription and its buffered value alive even after every subscriber has gone. Use shareReplay({ bufferSize: 1, refCount: true }) for streams that should stop when nobody's listening.
2. Registries in singletons. A root service that collects callbacks or component references:
@Injectable({ providedIn: 'root' })
export class ShortcutService {
private handlers: Array<() => void> = [];
register(fn: () => void) { this.handlers.push(fn); } // never removed
}
Every component that registers a handler (which closes over this) stays alive forever. Return an unregister function and call it from DestroyRef.onDestroy(), exactly as DestroyRef itself does.
3. Unbounded caches. A Map in a root service keyed by ID that only ever grows, especially if it stores component instances, DOM nodes or large responses. Cap it (an LRU), store plain data rather than live objects, or use a WeakMap when keys are objects.
4. Third-party instances. Chart, map and editor libraries attach listeners and timers of their own. If the library has a destroy(), dispose() or remove() method, call it:
constructor() {
afterNextRender(() => {
const chart = new Chart(this.canvas().nativeElement, this.config());
inject(DestroyRef).onDestroy(() => chart.destroy()); // wrong: inject() outside injection context
});
}
The line marked wrong is a common mistake of its own: inject() doesn't work inside a callback. Capture DestroyRef in the constructor and use it inside:
private destroyRef = inject(DestroyRef);
constructor() {
afterNextRender(() => {
const chart = new Chart(this.canvas().nativeElement, this.config());
this.destroyRef.onDestroy(() => chart.destroy());
});
}
5. Dynamic components and overlays. Components created with ViewContainerRef.createComponent() (lesson 2.14) live until their ComponentRef is destroyed or their container is cleared. CDK overlays hold a portal until you call detach() and dispose(). A dialog service that creates but never disposes is a leak per open.
6. Effects reading large, long-lived signals. An effect() in a root service that captures a big object in a closure keeps it alive as long as the service lives, which is the whole session. Keep root effects small, and store IDs rather than whole objects when you can.
Automate it #
Leaks come back when nobody's looking. Two ways to catch them early:
- Test that cleanup happens. Lesson 7.4's pattern,
fixture.destroy()followed by assertingsubject.observed === falseor thatclearIntervalwas called, catches the most common regressions in CI. - Run memlab. Meta's open-source memlab drives a browser through a scenario (visit page, perform action, go back), takes heap snapshots and reports objects that leaked, with retainer traces. A short scenario for your heaviest routes, run before releases, catches leaks that unit tests can't.
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
| Component count grows by one per visit | Something long-lived holds it; read the retainers | Remove the listener, subscription or registry entry on destroy |
Many Detached DOM nodes after navigation |
A destroyed view is still referenced | Follow the retainer path to the holder |
| Heap grows although subscriptions are cleaned up | shareReplay(1) on an infinite source |
shareReplay({ bufferSize: 1, refCount: true }) |
| Chart or map memory never released | Library instance not destroyed | Call its destroy() in DestroyRef.onDestroy() |
NG0203 inside afterNextRender or a callback |
inject() outside an injection context |
Capture DestroyRef in a field first |
| Memory looks leaked in dev but not in production | Dev-mode extras and DevTools' own references | Confirm with a production build and a forced GC |
What's next #
Lesson 8.8 brings the whole module together: a checklist of the patterns (OnPush plus signals, immutable updates, track, computed() over template methods, signal granularity) that keep an Angular app from doing work it doesn't need to. Lesson 8.9 then closes Module 8 with security and sanitization.
Try it yourself #
Pick the heaviest route in your app. Take a baseline heap snapshot, navigate to it and away ten times, force garbage collection, and take a second snapshot. Compare them, search for the route's main component class, and check the count is zero. If it isn't, follow the retainer path to the holder and fix it.
search_documentationThe retainer path ends at NotificationService.listeners, an array in a root service. OrderDialog‘s constructor calls notifications.onMessage(msg => this.show(msg)), which pushes an arrow function that closes over the dialog, and nothing ever removes it. So each dialog stays reachable from a service that lives for the whole session. Fix it in two places: make onMessage() return an unsubscribe function that removes the listener, and in OrderDialog call it from inject(DestroyRef).onDestroy(...). Better still, expose notifications as a signal or observable so components can use toSignal() or takeUntilDestroyed() and get cleanup for free. Re-run the same 10-open test afterwards: the count should be 0 after garbage collection.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


