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.
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
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


