Finding Subscription and Effect Leaks in Angular: A Codebase Audit [2026]

Link copied
Finding Subscription and Effect Leaks in Angular: A Codebase Audit [2026]

Finding Subscription and Effect Leaks in Angular: A Codebase Audit [2026]

Finding one leak with a heap snapshot is satisfying. Finding the other two hundred that way is not. A codebase that grew up before takeUntilDestroyed, toSignal and signals usually has the same few leak patterns repeated across dozens of components, and the efficient way to fix them is the same as any other code-quality problem: search for the patterns, fix them mechanically, and add a lint rule so they don't come back.

This is lesson 10.5 of the Angular Tutorial. It follows lesson 10.4 on duplicate requests. Lesson 8.7 showed how to prove a leak with heap snapshots and detached DOM trees; this lesson is the complement: a code-level audit that finds leak-prone patterns before they show up in a profiler, and the retrofits that fix each one.

What leaks, and what doesn't #

Not every subscribe() is a leak. What matters is whether the source outlives the component:

Source Completes on its own? Needs cleanup?
HttpClient request Yes, after one response or error Usually no, but an unfinished request still runs its callback after destroy
Router.events No Yes
Store selectors, service Subjects, BehaviorSubjects No Yes
interval, timer (repeating), fromEvent No Yes
Form valueChanges, statusChanges No Yes when the form can outlive the component (a shared or service-owned form); cheap insurance otherwise
effect() created in a component Destroyed with the component No, but external resources it opens need onCleanup
setInterval, addEventListener, ResizeObserver, third-party instances No Yes

The rule that falls out of this: anything long-lived needs a lifetime, and the modern way to give it one is to tie it to the component's DestroyRef. Lesson 7.4 covers those APIs in depth; here we apply them across a codebase.

Step 1: Inventory with searches #

Start with plain text searches. They're crude, but they give you a list and a count you can track over time:

# Manual subscriptions in components and directives
grep -rn --include=*.ts "\.subscribe(" src/app | grep -v ".spec.ts"

# Legacy cleanup scaffolding: takeUntil(this.destroy$) and ngOnDestroy
grep -rln --include=*.ts -e "ngOnDestroy" -e "takeUntil(this" src/app

# Timers and DOM listeners that need explicit cleanup
grep -rn --include=*.ts "setInterval(\|addEventListener(\|new ResizeObserver\|new IntersectionObserver" src/app

# Effects that touch external resources
grep -rn --include=*.ts -A8 "effect(" src/app | grep -E "setInterval|addEventListener|subscribe\(|new WebSocket"

Sort the hits into the buckets below. Most codebases find that three or four patterns account for nearly everything.

Prioritise before you fix #

A list of two hundred subscribe() calls is overwhelming, and not every entry is equally risky. Rank the hits by how much damage a leak would do:

Priority What to look for Why
High Subscriptions to never-completing sources (store selectors, Router.events, interval, WebSockets) in components that are created and destroyed often: routed pages, dialogs, list rows Every visit adds another live subscription that keeps the destroyed component in memory and keeps running its callback
Medium Long-lived subscriptions in components that exist once (the app shell, a persistent sidebar) They don't accumulate, but they become leaks the day the component is moved into a route or a dialog
Low One-shot HttpClient calls They complete on their own; the risk is a callback running against a destroyed component, not memory growth

Start with the high-priority group in your most-visited routes. If you have production analytics, the routes users navigate between most often are where leaks grow fastest. Fix a few components end to end, confirm with a heap snapshot as described in lesson 8.7, and then turn the fixes into a pattern the rest of the team can apply.

Step 2: Retrofit manual subscriptions #

Pattern A: subscribe in the constructor or ngOnInit, no cleanup #

// ✗ the subscription outlives the component
ngOnInit() {
  this.store.select(selectCart).subscribe(cart => (this.cart = cart));
}

If the value is only used for display, don't subscribe at all. Convert it to a signal; toSignal() subscribes immediately and unsubscribes when the component is destroyed:

cart = toSignal(this.store.select(selectCart), { requireSync: true });

Use requireSync: true when the source emits synchronously on subscribe (a BehaviorSubject, most store selectors); otherwise provide an initialValue or handle undefined.

If the subscription does real work (navigation, logging, writing to another service), keep the subscribe() and add takeUntilDestroyed():

private destroyRef = inject(DestroyRef);

constructor() {
  this.router.events
    .pipe(filter(e => e instanceof NavigationEnd), takeUntilDestroyed())
    .subscribe(() => this.analytics.pageView());
}

Called in a constructor or field initializer, takeUntilDestroyed() finds the DestroyRef itself. Anywhere later (ngOnInit, a click handler), pass it explicitly or you'll get NG0203:

ngOnInit() {
  this.form.valueChanges
    .pipe(debounceTime(300), takeUntilDestroyed(this.destroyRef))
    .subscribe(v => this.draft.save(v));
}

Put takeUntilDestroyed() last in the pipe. Operators after it, such as switchMap to an inner Observable, can keep a subscription alive after the outer one is torn down.

Pattern B: the destroy$ Subject #

The pre-v16 idiom works, but it's four pieces of boilerplate per component and easy to get half right (forgetting next(), or forgetting takeUntil on one of five subscriptions):

// before
private destroy$ = new Subject<void>();
ngOnInit() { this.source$.pipe(takeUntil(this.destroy$)).subscribe(...); }
ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); }

// after
private destroyRef = inject(DestroyRef);
ngOnInit() { this.source$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(...); }

This is mechanical enough to do with search and replace per file. Delete ngOnDestroy and the OnDestroy import only if nothing else lives there.

Pattern C: Subscription fields and unsubscribe() #

private sub?: Subscription;
ngOnInit() { this.sub = this.data$.subscribe(...); }
ngOnDestroy() { this.sub?.unsubscribe(); }

These are correct if every subscription is assigned. The bug is usually the second subscription added later without a field. Convert to toSignal() or takeUntilDestroyed() so cleanup no longer depends on remembering.

Pattern D: subscriptions in services #

Root services live as long as the app, so subscriptions inside them are usually fine. The leak happens when a service subscribes on behalf of a component, for example a method that subscribes and pushes into a callback the component passed in. Return the Observable (or a signal) instead and let the caller own the lifetime.

Step 3: Clean up non-RxJS resources with DestroyRef #

Timers, listeners and observers don't have takeUntil. Register cleanup with DestroyRef.onDestroy():

export class LiveClock {
  now = signal(new Date());

  constructor() {
    const id = setInterval(() => this.now.set(new Date()), 1000);
    inject(DestroyRef).onDestroy(() => clearInterval(id));
  }
}

onDestroy() returns a function that unregisters the callback. That's useful when the resource can be released early, so the destroy callback doesn't pile up or run twice:

private destroyRef = inject(DestroyRef);

startTracking(el: HTMLElement): () => void {
  const onScroll = () => this.markEngaged();
  el.addEventListener('scroll', onScroll, { passive: true });
  const unregister = this.destroyRef.onDestroy(() => el.removeEventListener('scroll', onScroll));

  return () => {                 // the caller can stop early
    el.removeEventListener('scroll', onScroll);
    unregister();                // nothing left to do on destroy
  };
}

For DOM listeners on elements the component owns, a template or host listener needs no cleanup at all. Reach for addEventListener only for elements outside the component (window, document) or for passive, high-frequency events.

Step 4: Add onCleanup to effects that open resources #

An effect is destroyed with its component, but anything it starts is not. If an effect opens a connection, timer or listener on each run, it must close it before the next run:

// ✗ every room change opens another socket; old ones keep running
effect(() => {
  const socket = new WebSocket(`/ws/rooms/${this.roomId()}`);
  socket.onmessage = e => this.messages.update(m => [...m, JSON.parse(e.data)]);
});

// ✓ the previous socket closes before the next run and on destroy
effect(onCleanup => {
  const socket = new WebSocket(`/ws/rooms/${this.roomId()}`);
  socket.onmessage = e => this.messages.update(m => [...m, JSON.parse(e.data)]);
  onCleanup(() => socket.close());
});

Audit every effect for subscribe, setInterval, setTimeout, addEventListener, new WebSocket and third-party constructors. Each needs a matching onCleanup. A subscribe() inside an effect is also a hint that toSignal() or a resource would be simpler.

Effects created outside an injection context with an explicit injector option are tied to that injector's lifetime. An effect created with a root injector from inside a component lives as long as the app; call destroy() on the returned EffectRef when you're done with it.

Step 5: Keep them out with lint rules #

Searches find today's leaks. Lint rules stop tomorrow's. Two widely used options:

Rule Package What it catches
prefer-takeuntil eslint-plugin-rxjs-angular (and maintained forks) A subscribe() in a component with no takeUntil (or an alias you configure)
no-ignored-subscription eslint-plugin-rxjs (and maintained forks) A subscribe() whose returned Subscription is ignored

Configure prefer-takeuntil to accept the modern operator and skip the legacy ngOnDestroy requirement:

{
  "rxjs-angular/prefer-takeuntil": [
    "error",
    { "alias": ["takeUntilDestroyed"], "checkDestroy": false }
  ]
}

Rule names and prefixes differ slightly between the forks, so check the README of the package you install. Start with warn, fix the backlog using the steps above, then switch to error.

Audit checklist #

Find Replace with
subscribe() that only sets a display field toSignal(), httpResource(), or the async pipe
subscribe() that performs side effects takeUntilDestroyed() as the last operator
destroy$ Subject + ngOnDestroy takeUntilDestroyed(this.destroyRef)
setInterval, addEventListener on window/document, observers DestroyRef.onDestroy() cleanup
Effect that opens a socket, timer or listener onCleanup() inside the effect
Service method subscribing for a component Return an Observable or signal; caller owns the lifetime

Gotchas #

Symptom Cause Fix
NG0203 after adding takeUntilDestroyed() Called in ngOnInit or a handler without an argument Pass this.destroyRef
Subscription still alive after destroy takeUntilDestroyed() placed before a switchMap or mergeMap Move it to the end of the pipe
toSignal() throws when the component is created requireSync: true with a source that doesn't emit synchronously Use initialValue, or remove requireSync
Old WebSocket messages arrive after switching rooms Effect opens a socket per run with no cleanup onCleanup(() => socket.close())
Listener on window keeps firing after navigation addEventListener without removal DestroyRef.onDestroy() to remove it
prefer-takeuntil flags subscriptions that already use takeUntilDestroyed() The rule only knows takeUntil by default Add takeUntilDestroyed to its alias option

What's next #

Lesson 10.6 tackles the bug that leaks often hide: race conditions. You'll choose between switchMap, mergeMap, concatMap and exhaustMap for real scenarios like typeahead, save buttons and upload queues, and see how resource(), httpResource() and AbortController cancel stale requests.

Try it yourself #

Run the first two searches from Step 1 on your project and count the results. Pick the component with the most subscribe() calls, convert display-only subscriptions to toSignal() and the rest to takeUntilDestroyed(), then record a heap snapshot before and after navigating away from it a few times, as in lesson 8.7.

YouWe have about 60 components with the destroy$ Subject pattern. Can we safely migrate them all to takeUntilDestroyed in one go, or are there traps?
Claude · used get_best_practicesMostly mechanical, with three traps to check per file. (1) Where the subscription is created matters: in a constructor or field initializer, takeUntilDestroyed() needs no argument; in ngOnInit or a handler you must pass an injected DestroyRef, otherwise you’ll get NG0203. Inject destroyRef once per component and always pass it to be safe. (2) Keep the operator in the same position as takeUntil(this.destroy$), which should be last; any file that has it before a switchMap or mergeMap already leaks inner subscriptions, so fix those while you’re there. (3) Some ngOnDestroy methods also close dialogs or clear timers; only delete the method when destroy$.next() and complete() were the only lines. Do it in batches of 10 to 15 components, run the test suite after each, and add the prefer-takeuntil rule with alias: ['takeUntilDestroyed'] as a warning so new code follows the pattern.

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 *