Home

Published

- 11 min read

TakeUntilDestroyed in Angular

img of TakeUntilDestroyed in Angular

Subscriptions in Angular 22+

In Single Page Applications lots of data are loaded asynchronously in various ways. Sometimes we just need Data once, other times we want to stay informed (through a subscription) on what is happening for as long as we have the page open.

The Problem

If you subscribe to an observable inside a component, you need to make sure to unsubscribe when the component is destroyed. Otherwise the subscription keeps running in the background and you end up with a memory leak.

This is one of the most common sources of bugs in Angular applications. When a component subscribes to an observable (like a timer, a Subject or an Observable), the subscription remains active even after the component is destroyed. This can lead to: Memory leaks (Subscriptions keep consuming memory and resources), Unexpected behavior (Old subscriptions continue to update state in destroyed components), Performance degradation (Multiple overlapping subscriptions accumulate over time)

This post explores different approaches to solve this problem, from manual unsubscription to modern RxJS operators.

1. Setup

This section establishes a complete example that we’ll use to demonstrate the subscription problem and progressively improve it through different solutions.

Data Service

We’ll create a DataService that fetches data from an external API. This service represents any data source in your Angular application (REST API, GraphQL, WebSocket, etc.):

   // data.service.ts
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class DataService {
  private http = inject(HttpClient);
  private apiUrl = 'https://api.thedogapi.com/v1/images/search?limit=2';

  getDogs(): Observable<unknown> {
    return this.http.get<unknown>(this.apiUrl);
  }
}

A container component

We need a parent component that can dynamically create and destroy our timer component so we can observe subscription behavior:

   <!-- takeuntil-example.html -->
<button (click)="timerIsActive() ? hideTimer() : showTimer()">Toggle Timer</button>

@if (timerIsActive()) {
<app-my-timer></app-my-timer>
}
   // takeuntil-example.ts
import { Component, signal } from '@angular/core';
import { MyTimer } from './my-timer/my-timer';

@Component({
  selector: 'app-takeuntil-example',
  imports: [MyTimer],
  templateUrl: './takeuntil-example.html',
  styleUrl: './takeuntil-example.scss'
})
export class TakeuntilExample {
  public timerIsActive = signal(false);

  public showTimer(): void {
    this.timerIsActive.set(true);
  }

  public hideTimer(): void {
    this.timerIsActive.set(false);
  }
}

The Problematic component

This is the child component where we’ll demonstrate the memory leak issue. It has both a long-running observable (timer) and a finite observable (API call):

   <!--  my-timer.html -->
<p>The timer: {{ secondsRunning() }}</p>
<p>The dogs: {{ dogs() | json }}</p>
   // my-timer.ts
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { Subscription, tap, timer } from 'rxjs';
import { DataService } from '../data.service';
import { JsonPipe } from '@angular/common';

@Component({
  selector: 'app-my-timer',
  imports: [JsonPipe],
  templateUrl: './my-timer.html',
  styleUrl: './my-timer.scss'
})
export class MyTimer implements OnInit, OnDestroy {
  private dataService = inject(DataService);

  public dogs = signal<unknown>(null);
  public secondsRunning = signal(0);

  private subscription = new Subscription();
  private timer = timer(0, 1000);

  constructor() {
    // todo
  }

  public ngOnInit(): void {
    // todo
  }

  public ngOnDestroy(): void {
    console.log('on Destroy');
  }
}

2. Memory Leak Setup

We have the setup that is as of now producing memory leaks. Loading Data from a Service never was a problem. Those observables are automatically completing after emitting a response. However we will still load data even if you close the component before receiving an answer.

   // my-timer.ts
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { Subscription, tap, timer } from 'rxjs';
import { DataService } from '../data.service';
import { JsonPipe } from '@angular/common';

@Component({
  selector: 'app-my-timer',
  imports: [JsonPipe],
  templateUrl: './my-timer.html',
  styleUrl: './my-timer.scss'
})
export class MyTimer implements OnInit, OnDestroy {
  private dataService = inject(DataService);

  public dogs = signal<unknown>(null);
  public secondsRunning = signal(0);

  private subscription = new Subscription();
  private timer = timer(0, 1000);

  constructor() {
    // this will leak
    this.timer.pipe(tap((value) => console.log(value))).subscribe({ 
      next: (value: number) => { 
        this.secondsRunning.set(value); 
      } 
    }); 
  }

  public ngOnInit(): void {
    // this is fine. it's a automatically completing observable after 1 answer
    this.dataService.getDogs().subscribe({ 
      next: (data: unknown) => { 
        console.log('Dogs received:', data); 
        this.dogs.set(data); 
      } 
    }); 
  }

  public ngOnDestroy(): void {
    console.log('on Destroy');
  }
}

The console output

This is the console output. You can see that the timer is now repeating multiple times

   0                               my-timer.ts:24
Dogs received: (2) [{…}, {…}]   my-timer.ts:35
1                               my-timer.ts:24
2                               my-timer.ts:24
on Destroy                      my-timer.ts:42
# here the component was destroyed. Notice that the subscription is still running
3                               my-timer.ts:24
4                               my-timer.ts:24
# here we created the component again. Notice we have 2 subscriptions now
0                               my-timer.ts:24
5                               my-timer.ts:24
# The Service call is there only once
Dogs received: (2) [{…}, {…}]   my-timer.ts:35
1                               my-timer.ts:24
6                               my-timer.ts:24
2                               my-timer.ts:24
7                               my-timer.ts:24
on Destroy                      my-timer.ts:42
3                               my-timer.ts:24
8                               my-timer.ts:24
4                               my-timer.ts:24
9                               my-timer.ts:24
5                               my-timer.ts:24

3. Classic way

The classic solution has been the manual approach: collect all subscriptions and clean them up in the ngOnDestroy lifecycle hook. This was the standard pattern before RxJS operators like takeUntil became available.

It is Explicit and easy to understand and works with any subscription. However there is some Boilerplate code and it is easy to forget to add new subscriptions to the collection

   // my-timer.ts
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { Subscription, tap, timer } from 'rxjs';
import { DataService } from '../data.service';
import { JsonPipe } from '@angular/common';

@Component({
  selector: 'app-my-timer',
  imports: [JsonPipe],
  templateUrl: './my-timer.html',
  styleUrl: './my-timer.scss'
})
export class MyTimer implements OnInit, OnDestroy {
  private dataService = inject(DataService);

  public dogs = signal<unknown>(null);
  public secondsRunning = signal(0);

  private subscription = new Subscription(); 
  private timer = timer(0, 1000);

  constructor() {
    this.subscription.add( 
      this.timer.pipe(tap((value) => console.log(value))).subscribe({ 
        next: (value: number) => { 
          this.secondsRunning.set(value); 
        } 
      }) 
    ); 
  }

  public ngOnInit(): void {
    this.dataService.getDogs().subscribe({
      next: (data: unknown) => {
        console.log('Dogs received:', data);
        this.dogs.set(data);
      }
    });
  }

  public ngOnDestroy(): void {
    console.log('on Destroy');
    this.subscription.unsubscribe();  
  }
}

The console output

This is the console output. We fixed the memory leak! But now we have to track the active subscriptions.

   0                                 my-timer.ts:24
Dogs received: (2) [{…}, {…}]     my-timer.ts:35
1                                 my-timer.ts:24
2                                 my-timer.ts:24
on Destroy                        my-timer.ts:42
0                                 my-timer.ts:24
Dogs received: (2) [{…}, {…}]     my-timer.ts:35
1                                 my-timer.ts:24
2                                 my-timer.ts:24
on Destroy                        my-timer.ts:42

4. Modern way

This approach uses the takeUntil operator from RxJS, which is much cleaner than the classic method. With takeUntil, you don’t need to manually track subscriptions. Instead, you pipe all observables through a “destroy” subject that signals when to unsubscribe.

How it works:

  1. Create a Subject called destroy$ in your component
  2. Add takeUntil(this.destroy$) to every observable’s pipe chain
  3. In ngOnDestroy, emit a value and complete the subject
  4. All piped observables automatically unsubscribe

Pros:

  • No need for a Subscription object
  • Cleaner than the classic approach
  • Can cancel API requests if the component is destroyed before they complete
  • Works with services too

Cons:

  • Still need to add takeUntil to every subscription
  • Still need to implement ngOnDestroy

Reference: https://www.learnrxjs.io/learn-rxjs/operators/filtering/takeuntil

   // my-timer.ts
import { Component, inject, OnInit, signal } from '@angular/core'; 
import { Subject, takeUntil, tap, timer } from 'rxjs'; 
import { DataService } from '../data.service';
import { JsonPipe } from '@angular/common';

@Component({
  selector: 'app-my-timer',
  imports: [JsonPipe],
  templateUrl: './my-timer.html',
  styleUrl: './my-timer.scss'
})
export class MyTimer implements OnInit, OnDestroy { 
export class MyTimer implements OnInit { 
  private dataService = inject(DataService);

  public dogs = signal<unknown>(null);
  public secondsRunning = signal(0);

  private subscription = new Subscription(); 
  private destroy$ = new Subject<void>(); 
  private timer = timer(0, 1000);

  constructor() {
    this.timer 
      .pipe( 
        takeUntil(this.destroy$), 
        tap((value) => console.log(value)) 
      ) 
      .subscribe({ 
        next: (value: number) => { 
          this.secondsRunning.set(value); 
        } 
      }); 
  }

  public ngOnInit(): void {
    this.dataService
      .getDogs()
      .pipe(takeUntil(this.destroy$)) 
      .subscribe({
        next: (data: unknown) => {
          console.log('Dogs received:', data);
          this.dogs.set(data);
        }
      });
  }

  public ngOnDestroy(): void {
    console.log('on Destroy');
    this.destroy$.next(); 
    this.destroy$.complete(); 
  }
}

The console output

This is the console output.

   0                               my-timer.ts:28 
1                               my-timer.ts:28 
2                               my-timer.ts:28 
Dogs received: (2) [{…}, {…}]   my-timer.ts:43 
3                               my-timer.ts:28 
on Destroy                      my-timer.ts:50
0                               my-timer.ts:28 
1                               my-timer.ts:28 
2                               my-timer.ts:28 
Dogs received: (2) [{…}, {…}]   my-timer.ts:43 
3                               my-timer.ts:28 
on Destroy                      my-timer.ts:50
0                               my-timer.ts:28 
1                               my-timer.ts:28 
on Destroy                      my-timer.ts:50
# Notice that we did not get the Dogs response after destroying the component.

Here we see that the request is properly canceled printed on paper glued to the glass

5. State of the art

Angular now provides the takeUntilDestroyed operator (since Angular 16), which is the best approach. It’s part of the @angular/core/rxjs-interop package and automatically unsubscribes when the component is destroyed. We can skip the ngOnDestroy entirely!

How it works:

  • takeUntilDestroyed() listens to the DestroyRef of the current injection context
  • When the component is destroyed, the DestroyRef emits and automatically completes all piped observables
  • Works in injection contexts (components, services, directives, etc.)
  • Can pass a custom DestroyRef if you need to tie subscriptions to a different lifecycle

This is the cleanest and most modern approach. There is no manual ngOnDestroy lifecycle hook needed. It’s automatically integrated with Angular’s dependency injection and works in services and anywhere with injection context

Reference: https://angular.dev/ecosystem/rxjs-interop/take-until-destroyed

   import { Component, inject, OnInit, signal } from '@angular/core';  
import { tap, timer } from 'rxjs';  
import { DataService } from '../data.service';
import { JsonPipe } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';  

@Component({
  selector: 'app-my-timer',
  imports: [JsonPipe],
  templateUrl: './my-timer.html',
  styleUrl: './my-timer.scss'
})
export class MyTimer implements OnInit {
  private dataService = inject(DataService);
  // this is optional and only needed outside of injection context
  private destroyRef = inject(DestroyRef);  

  public dogs = signal<unknown>(null);
  public secondsRunning = signal(0);

  private destroy$ = new Subject<void>();  
  private timer = timer(0, 1000);

  constructor() {
    this.timer
      .pipe(takeUntil(this.destroy$))  
      .pipe(takeUntilDestroyed()) // in an injection context we don't even need that
      .pipe(takeUntilDestroyed(this.destroyRef)  
        tap((value) => console.log(value))
      )
      .subscribe({
        next: (value: number) => {
          this.secondsRunning.set(value);
        }
      });
  }

  public ngOnInit(): void {
    this.dataService
      .getDogs()
      .pipe(takeUntil(this.destroy$))  
      .pipe(takeUntilDestroyed(this.destroyRef))  
      .subscribe({
        next: (data: unknown) => {
          console.log('Dogs received:', data);
          this.dogs.set(data);
        }
      });
  }

  public ngOnDestroy(): void { 
    console.log('on Destroy');  
    this.destroy$.next();  
    this.destroy$.complete();  
  } 
}

The console output

   0                                my-timer.ts:30
1                                my-timer.ts:30
2                                my-timer.ts:30
Dogs received: (2) [{…}, {…}]    my-timer.ts:46
3                                my-timer.ts:30
4                                my-timer.ts:30
# component killed and restarted later
0                                my-timer.ts:30
1                                my-timer.ts:30
2                                my-timer.ts:30
Dogs received: (2) [{…}, {…}]    my-timer.ts:46
3                                my-timer.ts:30
# component killed and restarted later
0                                my-timer.ts:30
1                                my-timer.ts:30
2                                my-timer.ts:30
# component killed. No Dogs log

cleaned up code

this is just the cleaned up code convenient to read

   import { Component, DestroyRef, inject, OnInit, signal } from '@angular/core';
import { tap, timer } from 'rxjs';
import { DataService } from '../data.service';
import { JsonPipe } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-my-timer',
  imports: [JsonPipe],
  templateUrl: './my-timer.html',
  styleUrl: './my-timer.scss'
})
export class MyTimer implements OnInit {
  private dataService = inject(DataService);
  private destroyRef = inject(DestroyRef);

  public dogs = signal<unknown>(null);
  public secondsRunning = signal(0);

  private timer = timer(0, 1000);

  constructor() {
    this.timer
      .pipe(
        takeUntilDestroyed(this.destroyRef),
        tap((value) => console.log(value))
      )
      .subscribe({
        next: (value: number) => {
          this.secondsRunning.set(value);
        }
      });
  }

  public ngOnInit(): void {
    this.dataService
      .getDogs()
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe({
        next: (data: unknown) => {
          console.log('Dogs received:', data);
          this.dogs.set(data);
        }
      });
  }
}

A Few Things To Keep In Mind

  • takeUntilDestroyed is an operator, so it needs to be part of a pipe. You cannot use it standalone.
  • It only works with observables. If you are using async pipes in your template, you do not need it at all because the async pipe unsubscribes automatically.
  • If you are using switchMap or other higher-order operators, takeUntilDestroyed will still work as expected because it completes the outer observable.

Summary

takeUntilDestroyed is a small but very useful operator that removes a lot of boilerplate from your Angular components. If you are still using the Subject + takeUntil pattern, it is worth switching to this operator. It is cleaner, less error-prone, and it works in services too.