Ch. 4 · Angular

Angular Unsubscribe: Which Subscriptions Leak and How to Clean Up

Which Angular subscriptions leak and which end on their own, measured with a live Subject, plus takeUntilDestroyed, toSignal and the async pipe.

~6 min readadvancedupdated Oct 7, 2026

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 });
}
TypeScript

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 1
Text

After 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 and BehaviorSubjects in root services (state stores, event buses)
  • router.events, ActivatedRoute.paramMap from a parent route that stays mounted, and other router streams
  • interval(), timer() with a period, fromEvent(window, 'resize')
  • WebSocket and server-sent-event streams
  • FormControl.valueChanges from 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

HttpClient completes 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 using switchMap so 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, ...
Text

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(...);
}
TypeScript

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: [] },
);
TypeScript

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.

Further reading

More in Angular

read ✓Angular · mid

Angular Async Validators and Pending State

A username-availability check traced step by step: PENDING status, cancelled checks, debouncing, errors that hang the form, and safe submit.

~6 min readread →
esc