AngularAtlased. 22.2

Ch. 16Advanced UI & performance71 / 75

Memory leaks: finding and fixing them

Subscriptions, timers and listeners that outlive their component — how to spot them in DevTools and close them.

~0 min read Official docs

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.

Why it matters
Destroying a component doesn't stop what it started. Angular removes the DOM and runs the destroy hooks; anything referenced from somewhere that lives longer — a root service, a timer, a global listener — still points to the component and to everything the component points to.

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());
  }
}
LiveOpen and close a widget that forgets to clean up
Close on destroy:

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
Press “Open and close 10 times”, then move the mouse over the page: destroyed widgets keep working. Tick the three boxes, repeat, and the counters stay at zero.

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));
  }
}
SourceWhat keeps it aliveThe fix
subscribe()the source keeps your callback, the callback keeps the componenttoSignal(), the async pipe or takeUntilDestroyed()
setIntervalthe browser keeps the callback until the timer is clearedDestroyRef.onDestroy(() => clearInterval(id))
addEventListenerwindow and document outlive every componenthost: { '(window:resize)': … }, Renderer2.listen or an AbortController
caches in servicesa root service lives as long as the app, and so does its Mapdelete the entry on destroy, or use a WeakMap keyed by the element
third-party widgetscharts, maps and editors register their own listeners and timerscall 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.

  1. 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.
  2. Press Open and close 10 times and wait for it to finish. Again Collect garbage and Take snapshot.
  3. With Snapshot 2 selected, switch the menu at the top left from Summary to Comparison (against Snapshot 1) and sort by # Delta.
  4. Type app-leaky-widget into Filter by class: +10, one host element per closed widget. Then DOMTimer (+10 timers still running) and EventListener (+10 listeners). On the published site class names are minified, so LeakyWidget only shows up when you run the app in development.
  5. 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.
  6. 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

Worth hunting
  • 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.
Not worth worrying about
  • 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() in ngOnInit, 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.
  • removeEventListener with 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()

Ch. 16 · 0 of 4 complete

Finished this lesson?

Mark it and it shows up on your map: in the contents and on the home page.

Marks itself as done once you have read the whole lesson.

  1. Web Workers
  2. Huge lists: virtual scroll
  3. Modern animations & View Transitions
  4. Memory leaks: finding and fixing them
See your whole map

All 75 pages · type to filter

  • 01What's new in Angular 21 & 22All the important changes in v21 and v22, briefly, with a link to the page that explains each one.Start
  • 01Your first app, step by stepA reading list built from an empty component to a finished app — signals, @for, inputs and outputs, a service and a form, one step at a time.Start
  • 01Quick decisions“Where do I put this?”, “Signal or Observable?”, “Shared or feature?” — short answers to everyday questions.Start
  • 01Angular interview questions60 real interview questions for junior, mid and senior, with the answer to give, the follow-up and the red flags. Plus a mock interview.Start
  • 02Standalone & bootstrapHow an app starts without NgModule: bootstrapApplication, app.config.ts, imports in the component.Modern fundamentals
  • 02Data binding & eventsInterpolation, property and attribute bindings, events with $event and key filters, and how [( )] is built — what goes into the DOM and what comes back.Modern fundamentals
  • 02Control flow: @if, @for, @switchThe block syntax that replaces *ngIf/*ngFor/ngSwitch, with track, @empty and exhaustive checks.Modern fundamentals
  • 02@let and the new template expressionsLocal variables with @let, arrow functions, spread, regex, instanceof, comments inside tags.Modern fundamentals
  • 02@defer — lazy loading in templatesLoad heavy components only when needed: triggers, prefetch, placeholder, loading, error.Modern fundamentals
  • 02Class & style bindingsWhy [class.x] and [style.x] replaced ngClass and ngStyle (with automatic migration from v21).Modern fundamentals
  • 02Component styles & encapsulation:host, the three encapsulation modes, theming with CSS variables, and why ::ng-deep is a last resort.Modern fundamentals
  • 02Pipes: when and howPure vs impure pipes, custom pipes, and how you choose between a pipe, computed() and a method.Modern fundamentals
  • 03signal() and computed()The reactive primitives: writable state, derived state, equality, untracked and immutability rules.Signals & reactivity
  • 03effect() — when YES, when NOSide effects synchronised with signals: localStorage, logging, imperative APIs. And their traps.Signals & reactivity
  • 03linkedSignal() — editable derived stateA signal that resets when its source changes, but that you can also set manually.Signals & reactivity
  • 03resource, rxResource, httpResourceAsync data as signals: status, value, error, reload, automatic cancellation. Stable since v22.Signals & reactivity
  • 03debounced() — delayed signalsThe “debounce” version of a signal, without RxJS. Perfect for search.Signals & reactivity
  • 03Lists: search, filter, sort, paginateA chain of computed signals that recalculates only what changed, with the filters kept in the URL so a link reopens the same view.Signals & reactivity
  • 03Observables from zeroWhat an Observable is, subscribe and unsubscribe, the operators you use every week, cold vs hot — and when a signal is enough.Signals & reactivity
  • 03Signals ↔ RxJStoSignal, toObservable, takeUntilDestroyed, outputFromObservable — and when RxJS remains the right choice.Signals & reactivity
  • 04input() and output()Inputs as signals (required, transform, alias) and typed outputs. Data down, events up.Components: communication
  • 04model() — two-way bindingBanana-in-a-box [( )] with a single API. When to use it, and when input + output is better.Components: communication
  • 04viewChild & contentChildQueries as signals: viewChild, viewChildren, contentChild, contentChildren, the read option.Components: communication
  • 04Lifecycle & DestroyRefWhich hooks still matter in the signals era, afterNextRender, DestroyRef and typed ngOnChanges.Components: communication
  • 04Content projectionng-content with select, multiple slots, default content and the trap of conditional projection.Components: communication
  • 04ng-template & ngTemplateOutletTemplates as values: render props in Angular, typed context and customising components.Components: communication
  • 05Smart vs DumbContainer vs presentational: who holds the state, who only displays, and how to split them properly.Component design
  • 05Generic components <T>A correctly typed DataTable<T> or Select<T>, with templates for cells.Component design
  • 05Dynamic componentsNgComponentOutlet, createComponent with inputBinding/outputBinding, and a dashboard built from config.Component design
  • 05Directives & host compositionAttribute directives, the host: {} metadata and the Directive Composition API (hostDirectives).Component design
  • 05Custom form controlsFormValueControl (Signal Forms) vs ControlValueAccessor: a star rating, in both variants.Component design
  • 05Change detectionOnPush is the default from v22, zone.js is gone from v21. What triggers rendering and how not to get burned.Component design
  • 06inject() & @Service()The new @Service() decorator from v22, inject() vs the constructor, and the injection context.Dependency Injection
  • 06Providers & the injector hierarchyRoot vs route vs component, useClass/useValue/useFactory/useExisting, and the optional/self/skipSelf modifiers.Dependency Injection
  • 06InjectionToken & provideX()Typed configuration, provideFeature() functions, makeEnvironmentProviders and initializers.Dependency Injection
  • 06injectAsync() — lazy servicesLoad a heavy service only when you need it, with prefetch on idle.Dependency Injection
  • 07Folder structurecore / shared / features, what goes into each, and what a feature looks like inside.Application architecture
  • 07Shared vs featureWhen to move a component into shared and (especially) when NOT to. A concrete checklist.Application architecture
  • 07Interfaces & modelsInline interface or separate file? DTO vs domain model, type vs interface, where to put them.Application architecture
  • 07State management with signalsSimple signal stores, facades, local vs global state, and when you need NgRx.Application architecture
  • 07Style guide & namingThe current official conventions: file names without suffixes, protected, readonly, member order.Application architecture
  • 07Barrels, aliases & boundariesindex.ts, path aliases, a feature’s public API and dependency rules that can be checked automatically.Application architecture
  • 07Errors: @boundary & ErrorHandlerThe new @boundary block from v22.2, the global ErrorHandler, HTTP errors and errors from resource.Application architecture
  • 08Modern routing & lazy loadingloadComponent, child routes, parameters as inputs and parameter inheritance (the new default in v22).Routing
  • 08Guards & resolverscanActivate, canMatch, canDeactivate and resolve as functions, with redirects via UrlTree / RedirectCommand.Routing
  • 09Signal Forms — the basicsForms built on signals: form(), [formField], schema-based validation. Stable since v22.Forms
  • 09Signal Forms — advancedCustom and cross-field validation, async/HTTP with debounce, dynamic lists, disabled/hidden, reusable schemas.Forms
  • 09Typed Reactive FormsTyped FormGroup, nonNullable, FormArray — and how they coexist with Signal Forms.Forms
  • 10HttpClient, interceptors & the API layerHttpClient without setup (v21), Fetch by default (v22), functional interceptors and an API layer with DTO mapping.HTTP & data
  • 11Performance — a checklistWhat really matters: OnPush + signals, track, @defer, lazy routes, images, bundles and hydration.Quality & performance
  • 11Testing with VitestVitest is the default runner from v21. TestBed, components with signals, services, HTTP and harnesses.Quality & performance
  • 11DevTools: look inside your appChrome DevTools and Angular DevTools step by step: what each panel shows, with exercises that work on this site.Quality & performance
  • 11Angular AriaHeadless, accessible components that you style however you like: tabs, accordion, menu, listbox...Quality & performance
  • 12SSR, prerender & render modesWhen to render on the server, when at build time and when only in the browser: RenderMode per route, app.routes.server.ts and the real costs.SSR & hydration
  • 12Hydration: event replay & incrementalHow the browser takes over server HTML without re-rendering it: full hydration, event replay, @defer (hydrate on …) and mismatch errors.SSR & hydration
  • 12Server-compatible codewindow doesn’t exist on the server: PLATFORM_ID, afterNextRender, DOCUMENT, TransferState, the HTTP cache and PendingTasks.SSR & hydration
  • 13Micro frontendsWhen it is worth splitting an app into several deployments and when it is just complexity: Native Federation, Module Federation, iframes, Web Components.Architecture at scale
  • 13Web Components with Angular ElementscreateCustomElement: an Angular component used as an HTML tag anywhere, plus communication between apps through events and BroadcastChannel.Architecture at scale
  • 13Monorepo, libraries & boundariesA workspace with several apps and libraries: ng generate library, Nx, secondary entry points, publishing and dependency rules.Architecture at scale
  • 13Hexagonal architecture (ports & adapters)A domain without Angular, ports as abstract classes and interchangeable adapters: HTTP, localStorage or in-memory, without touching the UI.Architecture at scale
  • 14Signals under the hoodThe reactive graph: producers and consumers, push-pull, why computed is lazy and glitch-free, the diamond problem and when effects run.Advanced reactivity
  • 14Advanced RxJS: higher-order operatorsswitchMap, mergeMap, concatMap and exhaustMap on a live diagram, plus retry with backoff, shareReplay and how to avoid leaks.Advanced reactivity
  • 14NgRx SignalStoreLarge-scale state management on signals: withState, withComputed, withMethods, rxMethod, entities and hooks — and when a simple store is enough.Advanced reactivity
  • 14State machines with signalsNo more impossible states: a typed machine on signals, with transitions, guards and a live state diagram.Advanced reactivity
  • 15WebSockets: real time that survives the networkA typed socket as signals: reconnect with backoff and jitter, heartbeat, an outbox while offline, and SSR safety.Real time & offline
  • 15Optimistic updates & rollbackAn instant UI that stays honest: snapshot and rollback, out-of-order responses, idempotency keys.Real time & offline
  • 15Offline-first: Service Worker & cachingThe Angular Service Worker: caching strategies, update prompts and an outbox for changes made offline.Real time & offline
  • 16Web WorkersHeavy computation without blocking the UI: a real worker in the page, message passing and when it is not worth it.Advanced UI & performance
  • 16Huge lists: virtual scroll100,000 rows without lag: CDK virtual scroll, itemSize, trackBy and the alternatives (pagination, infinite scroll).Advanced UI & performance
  • 16Modern animations & View Transitionsanimate.enter and animate.leave instead of the deprecated @angular/animations, CSS transitions and View Transitions on navigation.Advanced UI & performance
  • 16Memory leaks: finding and fixing themSubscriptions, timers and listeners that outlive their component — how to spot them in DevTools and close them.Advanced UI & performance
  • 17Security: XSS, sanitisation & CSPWhat Angular does automatically against XSS, when bypassSecurityTrust becomes dangerous, plus CSP with nonce, Trusted Types and XSRF.Security & globalisation
  • 17Authentication: tokens & refreshAn interceptor that attaches the token, refreshes once on 401 and replays the queued requests — plus where you keep the token.Security & globalisation
  • 17OAuth 2.1 & OpenID Connect with PKCEThe authorization code flow with PKCE, step by step: verifier and challenge, state and nonce, the ID token, and why a BFF is safer.Security & globalisation
  • 17Internationalisation (i18n)$localize, the i18n attribute, ICU plural/select, a build per language vs runtime translations, and locale-based formatting.Security & globalisation