Angular takeUntilDestroyed() and DestroyRef: Modern Cleanup [2026]

Link copied
Angular takeUntilDestroyed() and DestroyRef: Modern Cleanup [2026]

Angular takeUntilDestroyed() and DestroyRef: Modern Cleanup [2026]

Angular Tutorial › Module 7: RxJS › Lesson 7.4

For years, every Angular component that subscribed to anything carried the same ritual: a destroy$ subject, a takeUntil(this.destroy$) in every pipe, and an ngOnDestroy that called next() and complete(). Forget one line and you had a leak that only showed up after an hour of navigation. Modern Angular replaced the ritual with two small APIs, DestroyRef and takeUntilDestroyed(), and a framework that cleans up most things on its own.

This is lesson 7.4 of the Angular Tutorial and the last lesson of Module 7. Lesson 7.3 showed that toSignal() and rxResource() clean up after themselves. This lesson covers everything else: the subscriptions, listeners, timers and observers you still create by hand, how to tie each one to the right lifetime, and the handful of places where the modern APIs still let a leak through.

The mental model: cleanup belongs to a lifetime #

Every resource you create needs an owner whose destruction ends it. Angular gives each owner a DestroyRef:

Owner When its DestroyRef fires Typical resources
Component or directive When the view is destroyed (route change, @if false, parent destroyed) Subscriptions, DOM listeners, timers, observers
Service provided in a component's providers When that component is destroyed Per-instance state, polling
Service providedIn: 'root' When the application is destroyed (never, in a browser) App-wide streams, which usually shouldn't be cleaned up
Route or environment injector When that injector is destroyed Lazy-loaded feature state

The rule that follows from the table: clean up at the lifetime you created the resource in. A subscription created in a component should die with the component, not with the app.

DestroyRef: the primitive #

import { Component, DestroyRef, inject } from '@angular/core';

@Component({ selector: 'app-clock', template: `{{ now }}` })
export class Clock {
  protected now = new Date();

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

inject(DestroyRef) gives you the destroy hook of whatever you're being created in. onDestroy() registers a callback and returns a function that unregisters it, which is useful when the resource can end early:

const destroyRef = inject(DestroyRef);
const socket = new WebSocket(url);
const stop = destroyRef.onDestroy(() => socket.close());

socket.addEventListener('close', () => stop());   // closed by the server: no need to close again

Two properties make DestroyRef better than ngOnDestroy:

  • It composes. A helper function can take care of its own cleanup, so the component doesn't need to know what the helper allocated.
  • It works outside classes. Anything running in an injection context can use it, including functions called from a field initialiser.

That second point is what makes reusable cleanup possible:

export function injectInterval(ms: number, tick: () => void) {
  const id = setInterval(tick, ms);
  inject(DestroyRef).onDestroy(() => clearInterval(id));
}

export class Dashboard {
  constructor() {
    injectInterval(30_000, () => this.refresh());   // cleans itself up
  }
}

A destroyed flag tells you whether the owner is already gone. Check it when a callback can arrive late, such as an async response, before touching state or registering more cleanup.

takeUntilDestroyed(): DestroyRef for streams #

import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

export class Search {
  private route = inject(ActivatedRoute);
  protected query = '';

  constructor() {
    this.route.queryParamMap
      .pipe(takeUntilDestroyed())
      .subscribe(p => (this.query = p.get('q') ?? ''));
  }
}

The operator completes the stream when the current DestroyRef fires. It has been stable since Angular 19. Called without arguments it injects the DestroyRef itself, so it has the same constraint as inject(): it must run in an injection context. In ngOnInit, an event handler or a callback, pass one explicitly:

private destroyRef = inject(DestroyRef);

ngOnInit() {
  this.load$(this.id)
    .pipe(takeUntilDestroyed(this.destroyRef))
    .subscribe(data => (this.data = data));
}

The migration it replaces #

// Before: the destroy$ ritual
export class Legacy implements OnInit, OnDestroy {
  private destroy$ = new Subject<void>();
  ngOnInit() {
    this.events$.pipe(takeUntil(this.destroy$)).subscribe(e => this.handle(e));
    this.prices$.pipe(takeUntil(this.destroy$)).subscribe(p => this.update(p));
  }
  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

// After
export class Modern {
  constructor() {
    this.events$.pipe(takeUntilDestroyed()).subscribe(e => this.handle(e));
    this.prices$.pipe(takeUntilDestroyed()).subscribe(p => this.update(p));
  }
}

No subject, no interface, no hook, and nothing to forget in ngOnDestroy.

Put it last in the pipe #

The most common way to leak with takeUntilDestroyed() is putting it in the wrong place:

// Leaks: the inner interval keeps running after destroy
this.trigger$.pipe(
  takeUntilDestroyed(),
  switchMap(() => interval(1000)),
).subscribe(...);

// Correct: completion reaches everything upstream
this.trigger$.pipe(
  switchMap(() => interval(1000)),
  takeUntilDestroyed(),
).subscribe(...);

takeUntilDestroyed() completes its input. Any operator after it that opens a new inner subscription, like switchMap, mergeMap or shareReplay, can keep that inner stream alive. Keep it as the last operator before subscribe(). The RxJS ESLint rules for takeUntil placement catch this automatically, and they're worth enabling.

What you no longer need to clean up #

Knowing where cleanup is automatic stops you writing noise:

Source Cleanup needed? Why
toSignal(obs$) No Unsubscribes on the injection context's destroy (lesson 7.3)
rxResource() / httpResource() No Cancels the in-flight request on params change and destroy
async pipe in the template No Unsubscribes when the view is destroyed
effect() No, for the effect itself Destroyed with its owner; use onCleanup for resources it opens
HttpClient call subscribed once Usually no Emits once and completes; add cleanup only to cancel slow requests on navigation
output() / template (event) bindings No Angular removes them with the view
host: { '(window:resize)': ... } listeners No Removed with the host element
Manual subscribe() on a long-lived stream Yes takeUntilDestroyed()
addEventListener, setInterval, ResizeObserver, IntersectionObserver, WebSocket Yes DestroyRef.onDestroy()
Renderer2.listen() Yes It returns an unlisten function; call it in onDestroy

The effect() row deserves an example, because effects often start things:

effect((onCleanup) => {
  const el = this.panel().nativeElement;
  const ro = new ResizeObserver(() => this.measure());
  ro.observe(el);
  onCleanup(() => ro.disconnect());   // runs before the next run and on destroy
});

onCleanup runs both before the effect re-runs and when it's destroyed, so a changing signal never stacks observers.

Test that cleanup actually happens #

Cleanup bugs are invisible in normal use, which is why they're worth a test. Destroying the component in a test is one call, fixture.destroy(), and it triggers the same DestroyRef callbacks as a real navigation.

For a subscription, assert that the source loses its subscriber:

it('unsubscribes from prices on destroy', () => {
  const prices = new Subject<number>();
  TestBed.configureTestingModule({
    providers: [{ provide: PriceFeed, useValue: { prices$: prices.asObservable() } }],
  });
  const fixture = TestBed.createComponent(Ticker);
  fixture.detectChanges();
  expect(prices.observed).toBe(true);

  fixture.destroy();
  expect(prices.observed).toBe(false);   // takeUntilDestroyed() completed the stream
});

Subject.observed is true while anything is subscribed, so the test fails if someone later moves takeUntilDestroyed() above a switchMap or removes it.

For timers and listeners, spy on the global and check the matching teardown call:

it('clears its interval on destroy', () => {
  const clear = vi.spyOn(globalThis, 'clearInterval');
  const fixture = TestBed.createComponent(Clock);
  fixture.destroy();
  expect(clear).toHaveBeenCalledTimes(1);
});

The same pattern covers removeEventListener, ResizeObserver.prototype.disconnect and WebSocket.prototype.close. For helpers like injectInterval(), run them inside TestBed.runInInjectionContext() and destroy the environment with TestBed.resetTestingModule(), which fires the injector's DestroyRef. With these tests in place, the leak table below becomes a set of regressions your CI catches, not bugs your users find.

Leaks that still get through #

Symptom Cause Fix
NG0203 from takeUntilDestroyed() Called in ngOnInit, a handler or a callback without an argument Pass inject(DestroyRef) captured in a field
Timer or socket keeps running after navigation takeUntilDestroyed() placed before switchMap/mergeMap Move it to the end of the pipe
Subscription never ends in a service takeUntilDestroyed() inside a providedIn: 'root' service: its DestroyRef never fires Provide the service in the component, or return the stream and subscribe in the component
Memory grows as users navigate Global listeners (window, document) added with addEventListener and never removed DestroyRef.onDestroy(() => removeEventListener(...)) or a host listener
Callback runs after the component is gone Late async result writes to a destroyed component Check destroyRef.destroyed, or cancel the source with takeUntilDestroyed()
Observers stack up new ResizeObserver() inside an effect without onCleanup Disconnect in onCleanup

The root-service row catches experienced developers most often. takeUntilDestroyed() isn't wrong there; it just means "for the lifetime of the app", which is rarely what someone writing it intended.

To confirm a fix, take a heap snapshot in Chrome DevTools, navigate to the page and away a few times, take another, and search for your component's class name. A destroyed component shouldn't still be there. Module 8 covers that workflow in depth.

What's next #

That completes Module 7. You now have the full RxJS picture for modern Angular: when streams are still the right tool (7.1, 7.2), how to bridge them to signals (7.3), and how to end them cleanly (7.4). Module 8 moves to CSR performance: OnPush as the default, @for tracking, NgOptimizedImage, bundle analysis, and a practical memory-leak playbook that builds on this lesson.

Try it yourself #

Take a component that still uses the destroy$ pattern and migrate it: replace every takeUntil(this.destroy$) with takeUntilDestroyed() at the end of the pipe, move subscriptions into the constructor (or pass DestroyRef where they must stay in ngOnInit), and delete the subject and ngOnDestroy. Then add an injectInterval() helper like the one above and use it in two components.

If you want a second opinion on a component, you can ask Claude with the Angular CLI MCP server connected:

YouMy OrderStatus component uses a destroy$ subject with takeUntil in four pipes, plus a setInterval and a window resize listener. What’s the cleanest way to modernise its cleanup?
Claude · used get_best_practicesThree changes. (1) Replace each takeUntil(this.destroy$) with takeUntilDestroyed() as the last operator in the pipe, move those subscriptions into the constructor, then delete destroy$, OnDestroy and ngOnDestroy. Any subscription that must stay in ngOnInit gets takeUntilDestroyed(this.destroyRef) with destroyRef = inject(DestroyRef). (2) For the interval, register inject(DestroyRef).onDestroy(() => clearInterval(id)) right where you create it, or extract an injectInterval() helper. (3) Move the resize listener to the component’s host metadata, '(window:resize)': 'onResize()', so Angular removes it for you. Afterwards the component has no lifecycle hooks left, and each resource is cleaned up next to where it’s created.

Up next in Angular

More from this topic

View all Angular articles →

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 *