Angular Memory Leaks: Find and Fix Them With Heap Snapshots [2026]

Link copied
Angular Memory Leaks: Find and Fix Them With Heap Snapshots [2026]

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 ngOnInit runs 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:

  1. 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.
  2. 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.
  3. Return to the starting state, click the trash-can icon to force garbage collection, and take a second snapshot.
  4. In the second snapshot, choose Comparison view against the first. Sort by # Delta or Size Delta.
  5. 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.
  6. Also filter on Detached to find DOM nodes that are no longer in the document but still in memory.
  7. 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 asserting subject.observed === false or that clearInterval was 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.

YouAfter opening and closing the order dialog 10 times, the heap snapshot shows 10 OrderDialog instances. The retainer path ends at NotificationService. What’s happening?
Claude · used 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

View all Angular articles →
Angular

When Angular is launched ?

Link copied Angular When Angular is launched ? February 8, 2024 · 1 min read When was Angular launched? # Angu…

Feb 8, 2024 Read →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *