A memory leak in an Angular app rarely crashes anything. The tab just gets slower and heavier over an afternoon: every time a component is destroyed, something it started keeps running — a subscription, a timer, a listener on window — and keeps the whole component alive with it. Users who leave the app open all day pay for it.
This page shows the sources behind almost every leak, how to close each one, and how to find the ones you missed with Chrome DevTools. The lab creates real leaks in this tab and cleans them up when you leave the page.
The usual suspects
This widget looks harmless and contains the three classic leaks. Each callback captures this, and each one is held by something that lives longer than the widget: a root service, the browser's timer list, window. Open and close it a few times in the lab and watch what stays behind.
@Component({ selector: 'app-price-widget', template: `{{ price() }}` })
export class PriceWidget implements OnInit {
private readonly prices = inject(PriceFeed); // a root service: lives as long as the app
protected readonly price = signal(0);
ngOnInit() {
// ✗ 1. the feed keeps the callback, the callback keeps `this`, forever
this.prices.updates$.subscribe((p) => this.price.set(p));
// ✗ 2. the timer keeps running after the component is gone
setInterval(() => this.refresh(), 5000);
// ✗ 3. window outlives every component
window.addEventListener('resize', () => this.layout());
}
}No widget on the page.
- destroyed, still alive
- 0
- memory retained
- 0.00 MB
- zombie work
- 0/s
- subscriptions
- 0
- timers
- 0
- listeners
- 0
- widgets created
- 0
Every widget holds a 256 KB buffer, so the “retained” number is memory that really can't be freed while its zombie lives. The checkboxes apply to widgets created after you tick them; “Clean up the zombies” stops what the old ones left behind.
Closing each one
The rule is short: everything you start, you register a stop for, at the moment you start it. For observables, Angular has two tools that do it for you: toSignal() and takeUntilDestroyed(); for the rest there's DestroyRef.onDestroy(). Listeners declared in host are removed by Angular; for several listeners on global objects, an AbortController closes them all in one line.
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-price-widget',
template: `{{ price() }}`,
host: { '(window:resize)': 'layout()' }, // ✓ 3. Angular removes host listeners by itself
})
export class PriceWidget {
private readonly prices = inject(PriceFeed);
private readonly destroyRef = inject(DestroyRef);
// ✓ 1a. toSignal unsubscribes when the component is destroyed
protected readonly price = toSignal(this.prices.updates$, { initialValue: 0 });
constructor() {
// ✓ 1b. or, when you really need subscribe(): takeUntilDestroyed
this.prices.alerts$.pipe(takeUntilDestroyed()).subscribe((a) => this.notify(a));
// ✓ 2. every timer gets its "stop" registered next to its "start"
const id = setInterval(() => this.refresh(), 5000);
this.destroyRef.onDestroy(() => clearInterval(id));
}
}| Source | What keeps it alive | The fix |
|---|---|---|
subscribe() | the source keeps your callback, the callback keeps the component | toSignal(), the async pipe or takeUntilDestroyed() |
setInterval | the browser keeps the callback until the timer is cleared | DestroyRef.onDestroy(() => clearInterval(id)) |
addEventListener | window and document outlive every component | host: { '(window:resize)': … }, Renderer2.listen or an AbortController |
| caches in services | a root service lives as long as the app, and so does its Map | delete the entry on destroy, or use a WeakMap keyed by the element |
| third-party widgets | charts, maps and editors register their own listeners and timers | call their destroy() / remove() in onDestroy |
// ✗ a root service that remembers components (or their DOM) keeps them alive
@Service()
export class Tooltips {
private readonly open = new Map<string, TooltipComponent>(); // never deleted → leak
}
// ✓ delete on destroy, or let the garbage collector decide with a WeakMap
@Service()
export class Tooltips {
private readonly anchors = new WeakMap<HTMLElement, TooltipComponent>(); // key gone → entry gone
}What doesn't leak
Not every subscribe() is a problem. HttpClient requests complete on their own after the answer (with takeUntilDestroyed() a slow request is also cancelled when you leave). The async pipe, toSignal(), output() and host listeners clean up after themselves, and so do effect() and computed() created in a component. The Router cleans up the observables of ActivatedRoute. What leaks is a subscription to something that never completes and lives longer than you: a store, a Subject in a root service, interval, fromEvent(window, …), router events.
Finding leaks with Chrome DevTools
A leak is a number that grows when it should stay flat. The method is to repeat an action that should leave nothing behind — open and close a dialog, go to a page and back — and compare memory before and after.
- In the lab above, uncheck the three fixes. Open DevTools → the Memory panel → under Select profiling type choose Heap snapshot. Click the bin icon (Collect garbage), then Take snapshot.
- Press Open and close 10 times and wait for it to finish. Again Collect garbage and Take snapshot.
- With Snapshot 2 selected, switch the menu at the top left from Summary to Comparison (against Snapshot 1) and sort by # Delta.
- Type
app-leaky-widgetinto Filter by class: +10, one host element per closed widget. ThenDOMTimer(+10 timers still running) andEventListener(+10 listeners). On the published site class names are minified, soLeakyWidgetonly shows up when you run the app in development. - Click an instance and read the Retainers pane at the bottom: expand the rows to walk up the chain to whatever keeps it alive. Press Clean up the zombies, take a third snapshot, and the same rows show up under # Deleted.
- More direct: under Select profiling type choose Detached elements and press Get detached elements. You get one
<app-leaky-widget>per closed widget.
For a quick check without snapshots: the command menu (Ctrl/Cmd+Shift+P) → Show Performance monitor. Watch JS event listeners: it grows by 10 on every “10 times” with the fixes unchecked and does not come down until you clean up.
Every panel, step by step, with exercises and the pitfalls (for example DevTools keeping objects alive itself), is in DevTools: look inside your app.
A test that catches the leak next time
Once you've fixed a leak, make it impossible to bring back. A Subject knows whether anyone is still subscribed (observed); after fixture.destroy() the answer must be false. More about tests in testing with Vitest.
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
describe('PriceWidget', () => {
it('stops listening to the feed when destroyed', () => {
const feed = TestBed.inject(PriceFeed); // updates$ is a Subject inside the service
const fixture = TestBed.createComponent(PriceWidget);
fixture.detectChanges();
expect(feed.updates.observed).toBe(true);
fixture.destroy();
expect(feed.updates.observed).toBe(false); // nobody is left listening
});
});When YES, when NO
- Tabs that stay open for hours: dashboards, back-office tools, point of sale, chat.
- Components created and destroyed often: dialogs, list rows, tabs, routes you visit repeatedly.
- Code that subscribes to global things: stores, WebSockets, window events, router events.
- A one-off HttpClient request: it completes on its own.
- Subscriptions through the async pipe or toSignal(): they're cleaned up for you.
- A root service subscribing to something for the whole life of the app: that's intended, not a leak.
Common pitfalls
subscribe()inngOnInit, with no teardown, to something from a root service.- Trusting
take(1). If the source never emits — the user leaves before the first value — the subscription waits forever. removeEventListenerwith a different function..bind(this)creates a new function every time, so nothing is removed.- Components or DOM nodes in a service
Map. The service lives forever, and so do they. - Measuring without collecting garbage first. The heap goes up and down on its own; compare snapshots taken after Collect garbage.
Cheat sheet
subscribe() to a long-lived source takeUntilDestroyed() · toSignal() · async pipe
take(1) / first() only safe if the source WILL emit; otherwise it waits forever
setInterval / setTimeout chains DestroyRef.onDestroy(() => clearInterval(id))
window / document listeners host: { '(window:resize)': … } · Renderer2.listen · AbortController
removeEventListener needs the SAME function: keep the reference, not .bind() twice
root services no Map of components or DOM nodes; delete on destroy, or WeakMap
third-party widgets chart.destroy(), map.remove() in onDestroy
doesn't leak HttpClient (completes), async pipe, toSignal, outputs, host listeners
also safe effect() and computed() in components: destroyed with them
DevTools Memory → snapshot → repeat N× → GC → snapshot → Comparison
read the retainers Subscriber / Timeout / EventListener = who keeps it alive
"Detached" DOM nodes removed from the page but still referenced
in CI subject.observed === false after fixture.destroy()