skip to content

Subscription Teardown

takeUntilDestroyed() ends a subscription when its DestroyRef fires, replacing the takeUntil(destroy$) pattern. Interviewers ask which Angular subscriptions complete alone and which leak.

part ofAngularoverview, primer and where to startread it →
on this pageshow

explore

questions

5

In Angular, how does `takeUntilDestroyed()` from `@angular/core/rxjs-interop` end a subscription, and where can you call it without an argument?

level: juniorimportance: must knowfreq 75%

answer

  1. an operator, not a subscribe option
  2. DestroyRef onDestroy callback
  3. completes the stream
  4. constructor or field initializer
  5. last in the pipe

basics

~20 s

Angular's takeUntilDestroyed() pipes the source through takeUntil with a notifier fired by a DestroyRef's onDestroy callback, so the stream completes when the component, directive or injector is destroyed; without an argument it must run in an injection context.

solid answer

~40 s

`takeUntilDestroyed()` is an operator from `@angular/core/rxjs-interop`, stable since v19. Called with no argument, it does `inject(DestroyRef)`, so it must run in an **injection context**: a constructor, a field initializer, or code under `runInInjectionContext`. It then wraps the source in `takeUntil` with a notifier that fires from `destroyRef.onDestroy(...)`. When the component or directive is destroyed, the notifier emits, the stream **completes**, and the subscription is released. Its `DestroyRef` argument is how you use it elsewhere. Put it **last** in the `pipe`, right before `subscribe`, so no later operator can keep an inner subscription alive. It replaces the older `destroy$` subject plus `ngOnDestroy` boilerplate.

code

ts · 19 lines
ts
import { Component, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';

@Component({
  selector: 'app-breadcrumbs',
  template: `<nav>{{ lastUrl() }}</nav>`,
})
export class Breadcrumbs {
  readonly lastUrl = signal(''); // a signal, so the OnPush (v22 default) view updates

  constructor() {
    inject(Router).events.pipe(
      filter((e): e is NavigationEnd => e instanceof NavigationEnd),
      takeUntilDestroyed(), // injection context: no argument needed; last before subscribe
    ).subscribe(e => this.lastUrl.set(e.urlAfterRedirects));
  }
}

go deeper

for a junior

Recall the import path, that the no-argument form belongs in the constructor or a field initializer, and that it ends the subscription when the component is destroyed.

for a middle

Explain the takeUntil-plus-DestroyRef mechanism, that it completes rather than silently unsubscribes, and why it goes last in the pipe.

for a senior

Know what DestroyRef means in components versus root services, and spot operators after it that keep inner subscriptions alive.

for a principal

Set the codebase's teardown convention across takeUntilDestroyed, the async pipe and toSignal, and decide whether a lint rule should enforce it.

## What problem it solves A component that calls `subscribe()` on a long-lived Observable, such as `Router.events`, a shared service stream or an RxJS `interval`, must end that subscription when the component goes away. Otherwise the source keeps a reference to the callback, the callback keeps a reference to the component, and the callback keeps running for a component that is no longer on screen. Angular's answer is **`takeUntilDestroyed`**, exported from **`@angular/core/rxjs-interop`**. It was added in v16 as a developer preview and **promoted to stable in v19**. ## How it works The implementation is short, and knowing it answers most follow-up questions: 1. If no `DestroyRef` is passed, it calls `inject(DestroyRef)`. In development mode it first asserts that it is in an injection context. 2. It builds a small **notifier Observable** that registers `subscriber.next` with `destroyRef.onDestroy(...)` and returns the unregister function, so an early unsubscribe also removes the destroy callback. 3. It returns `source => source.pipe(takeUntil(notifier))`. When the owner is destroyed, the `onDestroy` callback fires, the notifier emits, and `takeUntil` **completes** the stream. Completion unsubscribes from the source. A subscriber's `complete` callback therefore runs on destroy, which is sometimes a surprise. Since v20, if the `DestroyRef` is **already destroyed** when the stream is subscribed, the notifier checks `destroyRef.destroyed`, emits immediately, and the stream completes at once. ## What DestroyRef represents `DestroyRef` is an injectable token whose meaning depends on where it is injected: | Injected in | Callbacks run when | |---|---| | a component or directive | that component or directive is destroyed | | a service provided in an environment injector (for example `providedIn: 'root'`) | that injector is destroyed, which for root means the application | This is why the operator gives per-screen cleanup in components and directives but not inside a root service: the root injector lives as long as the app. ## Where the no-argument form works An **injection context** is the window in which `inject()` is allowed: - the constructor of a component, directive, pipe or service; - a field initializer of such a class; - a factory function run by DI, or a function passed to `runInInjectionContext`. `ngOnInit`, event handlers, `setTimeout` callbacks and methods called later are **not** injection contexts. There you inject `DestroyRef` once into a field and pass it: `takeUntilDestroyed(this.destroyRef)`. ## Placement in the pipe `takeUntilDestroyed` completes whatever is **upstream** of it. Operators placed **after** it can still hold subscriptions of their own. For example, `switchMap` only completes when both its source and its current inner Observable have completed, so `takeUntilDestroyed()` placed before a `switchMap` to an endless inner stream leaves that inner stream running. The rule of thumb is simple: - put `takeUntilDestroyed(...)` as the **last operator** before `subscribe()`. ## When you do not need it - A stream bound in the template with the **async pipe** is unsubscribed by the pipe. - A stream converted with **`toSignal()`** is cleaned up with its injection context. - A request that **completes on its own**, such as an `HttpClient` call, ends without help, although tying it to `DestroyRef` still stops its callback running after the component is gone. ## What interviewers listen for - It **completes** the stream via `takeUntil` driven by `DestroyRef.onDestroy`. - No-argument calls need an **injection context**; elsewhere pass a **`DestroyRef`**. - `DestroyRef` means **component or directive destroy** in a component, **injector destroy** in a root service. - It belongs **last** in the pipe.

  • Does takeUntilDestroyed unsubscribe silently or complete the stream?
    It completes it. It is built on `takeUntil`, so when the `DestroyRef` callback fires, the subscriber's `complete` handler runs and the source subscription is released. Code in a `complete` callback or a `finalize` operator therefore runs on destroy.
  • What happens if you subscribe with takeUntilDestroyed(ref) after that DestroyRef was already destroyed?
    Since v20 the notifier checks `destroyRef.destroyed` and emits immediately, so the stream completes at once and a late subscription cannot outlive its owner.
  • Why is takeUntilDestroyed() in a providedIn: 'root' service rarely useful?
    In a root service `inject(DestroyRef)` returns the root environment injector, whose destroy callbacks only run when the application is destroyed. The subscription therefore lives for the whole app, which is correct for app-wide state but does not clean up per-screen work.

It is like a hotel key card programmed to stop working at checkout: you do not have to remember to hand it back, because the checkout event (DestroyRef) switches it off for you.

saying these in an interview costs you the question

  • takeUntilDestroyed() can be called anywhere, including ngOnInit, without an argument
  • takeUntilDestroyed unsubscribes without completing the stream
  • The position of takeUntilDestroyed in the pipe does not matter
  • takeUntilDestroyed in a root service ends the subscription when the calling component is destroyed
  • takeUntilDestroyed is still a developer-preview API
open as a page

In an Angular component, which subscriptions end on their own and which need teardown: HttpClient calls, `Router.events`, a FormControl's `valueChanges`, and RxJS `interval()`?

level: middleimportance: must knowfreq 65%

basics

~10 s

In Angular, HttpClient calls complete after the response, but Router.events, valueChanges and RxJS interval() never complete, so their subscriptions outlive the component unless tied to DestroyRef, the async pipe or toSignal.

open as a page

In an Angular component, why does calling `takeUntilDestroyed()` inside `ngOnInit` or a click handler throw NG0203, and how do you fix it?

level: middleimportance: should knowfreq 55%

basics

~10 s

Angular's takeUntilDestroyed() without an argument injects DestroyRef, which is only allowed in an injection context; ngOnInit and event handlers run later, so it throws NG0203. Inject DestroyRef into a field and pass it in.

open as a page

In an Angular codebase, how does the `takeUntil(this.destroy$)` teardown pattern work, where does it go wrong, and how does `takeUntilDestroyed` replace it?

level: seniorimportance: should knowfreq 50%

basics

~10 s

In Angular's older pattern a component keeps a private Subject, pipes streams through takeUntil(this.destroy$) and calls destroy$.next() in ngOnDestroy; takeUntilDestroyed does the same through DestroyRef without the Subject or hook.

open as a page

An Angular edit dialog subscribes to its form's `valueChanges` to autosave; after opening it ten times, one keystroke sends ten saves. What is happening, and how do you fix it?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Each dialog instance left a valueChanges subscriber behind, because the form outlives the dialog or the dialog is never destroyed; tie the subscription to the dialog's DestroyRef with takeUntilDestroyed and make sure closing calls ComponentRef.destroy().

open as a page