An RxJS subscription doesn’t know anything about Angular components. When you call subscribe() in a component, you’ve created a link from the source to your callback, and that link stays until the source completes, errors, or someone unsubscribes. Destroying the component doesn’t touch it. If the source is a root service that lives for the whole session, your callback lives for the whole session too, still holding a reference to the destroyed component.
That’s the leak. It’s quiet: no error, just callbacks piling up, memory growing, and occasionally a handler that runs against a component that’s gone. This note shows it happening, explains which sources need cleanup, and compares the tools Angular 19 and 20 give you. The numbers come from a run under Angular 20.3’s TestBed in Node.
Watching a leak happen
A root service exposes a stream of price ticks. Three components consume it in different ways:
@Injectable({ providedIn: 'root' })
export class Prices {
ticks = new Subject<number>();
}
// 1. Subscribes and never cleans up
export class Leaky {
private prices = inject(Prices);
ngOnInit() {
this.prices.ticks.subscribe((p) => this.render(p));
}
}
// 2. Tied to the component with takeUntilDestroyed
export class Managed {
constructor() {
inject(Prices).ticks.pipe(takeUntilDestroyed()).subscribe((p) => this.render(p));
}
}
// 3. Converted to a signal
export class Sig {
price = toSignal(inject(Prices).ticks, { initialValue: 0 });
}The test mounted three Leaky components, one Managed and one Sig, emitted one tick, destroyed all five, then emitted two more:
observers while mounted: 5 leaky handled 3, managed handled 1
Managed: finalize ran
Sig: finalize ran
observers after destroy: 3 leaky handled 9, managed handled 1After destroy, the Subject still has three observers: one per Leaky instance. Those three destroyed components handled both post-destroy ticks (3 + 6 = 9). Each one is a closure keeping a dead component in memory. In a real app, picture a list page that users open fifty times in a session, each visit adding a subscriber that never goes away.
Managed and Sig both ran their finalize when destroyed and stopped receiving values.
Which sources actually need cleanup
Not every subscribe() is a leak. The question is whether the source outlives the subscriber.
Finite sources complete by themselves. of(1, 2), from([...]) and HttpClient calls emit and then complete. Completion tears the subscription down. A test with of(1, 2).pipe(finalize(...)) confirmed finalize ran without any unsubscribe.
Long-lived sources don’t. These need explicit cleanup:
Subjects andBehaviorSubjects in root services (state stores, event buses)router.events,ActivatedRoute.paramMapfrom a parent route that stays mounted, and other router streamsinterval(),timer()with a period,fromEvent(window, 'resize')- WebSocket and server-sent-event streams
FormControl.valueChangesfrom a form that lives longer than the subscriber
Sources owned by the component die with it, mostly. valueChanges on a form the component created, or EventEmitter outputs of its own children, are collected together with the component. Many teams still add teardown for consistency, which is a reasonable rule because ownership is easy to get wrong when code moves.
Note
HttpClientcompletes after one response, but that doesn’t make it free. If the user leaves the page while a slow request is in flight, the callback still runs when it answers, and it may write into a destroyed component or navigate somewhere unexpected. Tying HTTP subscriptions to the component (or usingswitchMapso a new request cancels the old one) aborts the request on destroy.
The tools, and when each fits
takeUntilDestroyed() from @angular/core/rxjs-interop completes the stream when the current component, directive or service is destroyed. Without an argument it injects DestroyRef, so it only works in an injection context. Calling it from ngOnInit failed in the test with:
NG0203: takeUntilDestroyed() can only be used within an injection context such as a constructor, ...In a lifecycle hook or method, pass the DestroyRef you captured as a field:
private destroyRef = inject(DestroyRef);
ngOnInit() {
this.prices.ticks.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(...);
}Put it last in the pipe, or at least after any operator that creates inner subscriptions. If takeUntilDestroyed() comes before a switchMap, it completes the outer stream on destroy, but an inner observable that’s still running isn’t cancelled.
toSignal() subscribes immediately and unsubscribes when the injection context that created it is destroyed. It’s the best choice when you need the value for a template or a computed. Remember it subscribes eagerly: a cold HTTP observable passed to toSignal() fires the request at construction time.
The async pipe subscribes when the view renders and unsubscribes when the view is destroyed, and it marks OnPush views for check. It’s still perfectly good, especially for code that isn’t on signals yet.
DestroyRef.onDestroy(fn) is the general hook for non-RxJS teardown: removing a manual event listener, clearing a setInterval, destroying a third-party widget.
The old pattern, a destroy$ = new Subject<void>() with takeUntil(this.destroy$) and this.destroy$.next() in ngOnDestroy, still works. It’s more code and easier to get wrong (forgetting next(), or putting takeUntil before a switchMap), which is why takeUntilDestroyed replaced it in most codebases.
Subscriptions inside services
A component-scoped service, one listed in a component’s providers, is destroyed with that component, and takeUntilDestroyed() inside it works the same way. A root service is destroyed only when the application is. So subscriptions inside root services, such as a store listening to a WebSocket, are usually meant to be permanent. The leak pattern there is different: a root service that accepts callbacks or creates subscriptions on behalf of components, and never releases them when those components go away. Have such methods return an Observable the caller subscribes to (and cleans up), rather than taking a callback.
Finding leaks in a real app
In Chrome DevTools, take a heap snapshot, open and close the suspect page five times, force garbage collection, take another snapshot, and search the comparison for your component’s class name. If you see five detached instances, something still references them, and the retainer tree usually points straight at a Subscriber. Counting subject.observers.length in a debug build, as the test above does, is a quicker check for a specific stream.
Interview practice
A dashboard component subscribes in ngOnInit to this.route.paramMap, and in the callback it calls this.http.get('/api/widgets/' + id).subscribe(w => this.widgets = w). Users report that switching quickly between dashboards sometimes shows the wrong dashboard’s widgets. Is this a leak, a race, or both, and how would you rewrite it?
Answer
It’s mainly a race, with a smaller leak risk. Each param change starts a new HTTP request, and nothing cancels the previous one. If dashboard 1 is slow and the user switches to dashboard 2, response 2 can arrive first and response 1 can arrive second, overwriting the screen with the wrong data. The nested subscribe() is the giveaway: inner subscriptions created in a callback aren’t managed by anything.
The paramMap subscription itself isn’t a big leak when the component is the route’s own component, because the route and its observables are torn down with it, but it’s still unmanaged, and in a component that outlives the route it would leak.
I’d flatten it into one pipeline with switchMap, which unsubscribes from the previous request (and aborts it) when a new id arrives, and tie the result to the component:
private route = inject(ActivatedRoute);
private http = inject(HttpClient);
widgets = toSignal(
this.route.paramMap.pipe(
map((p) => p.get('id')!),
switchMap((id) => this.http.get<Widget[]>(`/api/widgets/${id}`)),
),
{ initialValue: [] },
);toSignal handles teardown when the component is destroyed, and switchMap guarantees only the latest dashboard’s response is ever rendered. The note on RxJS mapping operators covers why mergeMap or concatMap would each be wrong here.