Preventing RxJS Subscription Memory Leaks & takeUntilDestroyed()
These two questions are closely related.
The main idea is:
A subscription can become a problem when it remains active after the component no longer needs it.
Modern Angular provides several ways to manage this, and takeUntilDestroyed() is one of the most convenient for subscriptions created in component/directive code.
1. How Do You Prevent Subscription Memory Leaks?
What is a subscription memory leak?
When you call:
observable$.subscribe(...)
RxJS creates a subscription.
For a long-lived Observable, that subscription can continue receiving values until:
- the Observable completes,
- the Observable errors,
- or the subscription is unsubscribed.
If a component is destroyed but an unnecessary subscription remains active, it can keep executing callbacks and retaining resources.
Example:
interval(1000)
doesn't complete by itself.
If we subscribe:
interval(1000)
.subscribe(value => {
console.log(value);
});
it continues:
0
1
2
3
4
5
...
If that subscription outlives the component, it can become a leak.
Full Example — Problem
timer-demo.ts
import { Component, OnInit } from '@angular/core';
import { interval } from 'rxjs';
@Component({
selector: 'app-timer-demo',
standalone: true,
template: `
<h2>Timer</h2>
`
})
export class TimerDemo implements OnInit {
ngOnInit(): void {
interval(1000)
.subscribe(value => {
console.log(value);
});
}
}
The Observable:
interval(1000)
│
▼
0 → 1 → 2 → 3 → 4 → 5 → ...
If the component is removed, we want its subscription to stop.
2. Common Ways to Prevent Subscription Leaks
The main approaches are:
| Method | Best use |
|---|---|
AsyncPipe |
Observable values displayed in templates |
takeUntilDestroyed() |
Imperative subscriptions in Angular code |
take(1) |
Only one emission is needed |
first() |
First matching/available emission |
Manual unsubscribe() |
When explicit subscription control is required |
takeUntil() |
Older/custom notifier-based lifecycle pattern |
For modern Angular component code, two especially useful approaches are:
Template subscription
↓
AsyncPipe
TypeScript subscription
↓
takeUntilDestroyed()
3. Method 1 — Use AsyncPipe
If the Observable exists mainly to display data in the template, AsyncPipe is usually the cleanest option.
Component
import { AsyncPipe } from '@angular/common';
import { Component } from '@angular/core';
import { interval } from 'rxjs';
@Component({
selector: 'app-timer',
standalone: true,
imports: [AsyncPipe],
template: `
<h2>
Counter: {{ counter$ | async }}
</h2>
`
})
export class Timer {
counter$ = interval(1000);
}
No:
.subscribe(...)
is required in the component.
Angular's AsyncPipe manages the subscription lifecycle.
Component created
│
▼
AsyncPipe subscribes
│
▼
0 → 1 → 2 → 3...
│
▼
Component/View destroyed
│
▼
AsyncPipe unsubscribes
4. Method 2 — takeUntilDestroyed()
This is the main answer to your second question.
What is takeUntilDestroyed()?
takeUntilDestroyed() is an Angular RxJS interop operator that automatically completes/unsubscribes the subscription chain when the associated Angular context is destroyed.
It comes from:
import {
takeUntilDestroyed
} from '@angular/core/rxjs-interop';
Simple definition
takeUntilDestroyed()automatically stops an Observable subscription when the Angular component/directive is destroyed.
5. Full Simple takeUntilDestroyed() Example
import {
Component,
DestroyRef,
inject
} from '@angular/core';
import { interval } from 'rxjs';
import {
takeUntilDestroyed
} from '@angular/core/rxjs-interop';
@Component({
selector: 'app-timer',
standalone: true,
template: `
<h2>Timer Example</h2>
<p>{{ counter }}</p>
`
})
export class Timer {
private destroyRef = inject(DestroyRef);
counter = 0;
constructor() {
interval(1000)
.pipe(
takeUntilDestroyed(this.destroyRef)
)
.subscribe(value => {
this.counter = value;
});
}
}
6. How Does takeUntilDestroyed() Work?
We have:
interval(1000)
which produces:
0 → 1 → 2 → 3 → 4 → 5 → ...
Then:
.pipe(
takeUntilDestroyed(this.destroyRef)
)
connects the Observable lifecycle to the Angular destruction lifecycle.
Conceptually:
Component created
│
▼
Subscribe
│
▼
interval()
│
▼
0 → 1 → 2 → 3 → ...
│
Component destroyed
│
▼
DestroyRef triggered
│
▼
takeUntilDestroyed()
│
▼
Subscription stops
You don't have to manually write:
ngOnDestroy()
just for this subscription cleanup.
7. What is DestroyRef?
DestroyRef provides a way to register work associated with the destruction of the current Angular lifecycle context.
We can inject it:
private destroyRef = inject(DestroyRef);
Then:
takeUntilDestroyed(this.destroyRef)
knows when that context is destroyed.
Think of it as:
DestroyRef
│
▼
"Tell me when this Angular context
is being destroyed."
takeUntilDestroyed() uses that lifecycle signal to terminate the Observable subscription.
8. Can We Use takeUntilDestroyed() Without Passing DestroyRef?
Yes, when it is called within an Angular injection context, you can write:
interval(1000)
.pipe(
takeUntilDestroyed()
)
.subscribe(...);
For example, using it during construction:
import { Component } from '@angular/core';
import { interval } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-timer',
standalone: true,
template: `
<p>{{ counter }}</p>
`
})
export class Timer {
counter = 0;
constructor() {
interval(1000)
.pipe(
takeUntilDestroyed()
)
.subscribe(value => {
this.counter = value;
});
}
}
Angular can obtain the destruction context automatically there.
9. What About ngOnInit()?
This distinction is important.
If you want to use it in a method such as:
ngOnInit()
a clear approach is to inject DestroyRef and pass it explicitly.
Full Example
import {
Component,
DestroyRef,
inject,
OnInit
} from '@angular/core';
import { interval } from 'rxjs';
import {
takeUntilDestroyed
} from '@angular/core/rxjs-interop';
@Component({
selector: 'app-timer',
standalone: true,
template: `
<h2>Counter</h2>
<p>{{ counter }}</p>
`
})
export class Timer implements OnInit {
private destroyRef = inject(DestroyRef);
counter = 0;
ngOnInit(): void {
interval(1000)
.pipe(
takeUntilDestroyed(this.destroyRef)
)
.subscribe(value => {
this.counter = value;
});
}
}
This is explicit and easy to understand:
takeUntilDestroyed(this.destroyRef)
10. Old Approach — Manual unsubscribe()
Before these newer lifecycle helpers, you would commonly store the subscription.
import {
Component,
OnDestroy,
OnInit
} from '@angular/core';
import {
interval,
Subscription
} from 'rxjs';
@Component({
selector: 'app-timer',
standalone: true,
template: `
<p>{{ counter }}</p>
`
})
export class Timer implements OnInit, OnDestroy {
counter = 0;
private subscription!: Subscription;
ngOnInit(): void {
this.subscription =
interval(1000)
.subscribe(value => {
this.counter = value;
});
}
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
}
Flow:
ngOnInit()
│
▼
subscribe()
│
▼
Subscription stored
│
▼
Component destroyed
│
▼
ngOnDestroy()
│
▼
unsubscribe()
This works, but requires more manual code.
11. Old Common Pattern — takeUntil()
Another widely used pattern is:
private destroy$ = new Subject<void>();
Then:
observable$
.pipe(
takeUntil(this.destroy$)
)
.subscribe(...);
Finally:
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
Full Example
import {
Component,
OnDestroy,
OnInit
} from '@angular/core';
import {
interval,
Subject,
takeUntil
} from 'rxjs';
@Component({
selector: 'app-timer',
standalone: true,
template: `
<p>{{ counter }}</p>
`
})
export class Timer implements OnInit, OnDestroy {
private destroy$ =
new Subject<void>();
counter = 0;
ngOnInit(): void {
interval(1000)
.pipe(
takeUntil(this.destroy$)
)
.subscribe(value => {
this.counter = value;
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
This is still valid, and you'll see it in many existing Angular projects.
12. takeUntil() vs takeUntilDestroyed()
The older pattern requires:
private destroy$ =
new Subject<void>();
plus:
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
With takeUntilDestroyed():
private destroyRef = inject(DestroyRef);
and:
observable$
.pipe(
takeUntilDestroyed(this.destroyRef)
)
.subscribe(...);
No dedicated destroy$ Subject is necessary.
Comparison
takeUntil() |
takeUntilDestroyed() |
|---|---|
| RxJS operator | Angular RxJS interop operator |
| Requires notifier Observable | Uses Angular destruction lifecycle |
Often uses destroy$ Subject |
No destroy$ needed |
Often needs ngOnDestroy() |
Usually no cleanup hook needed |
| More boilerplate | Less boilerplate |
| Common in older/existing code | Convenient modern Angular approach |
13. take(1)
Sometimes you know that you only need the first emission.
observable$
.pipe(
take(1)
)
.subscribe(value => {
console.log(value);
});
After one value:
Observable
A → B → C → D
│
▼
take(1)
│
▼
A
│
▼
Complete
Because the chain completes after one value, it doesn't remain subscribed indefinitely.
But don't add take(1) merely as a generic cleanup mechanism if the stream logically needs multiple values.
14. Do Angular HttpClient Subscriptions Always Need Manual Cleanup?
Usually, a normal one-shot HTTP request completes automatically.
For example:
this.http
.get<Product[]>('/api/products')
.subscribe(products => {
console.log(products);
});
The normal lifecycle is:
Subscribe
│
▼
HTTP request
│
▼
Response
│
▼
next()
│
▼
complete()
Once completed, that subscription is no longer an ongoing stream.
So a simple finite HttpClient request is different from a long-lived stream such as:
interval()
or other streams that can remain active.
However, lifecycle-bound cancellation can still be useful for in-flight requests when the component disappears, and takeUntilDestroyed() provides a consistent way to do that.
15. Example: Multiple Subscriptions
Suppose your component has:
this.route.params
and:
this.form.valueChanges
and:
interval(1000)
You can apply the same lifecycle rule to each:
this.route.params
.pipe(
takeUntilDestroyed(this.destroyRef)
)
.subscribe(...);
this.form.valueChanges
.pipe(
takeUntilDestroyed(this.destroyRef)
)
.subscribe(...);
interval(1000)
.pipe(
takeUntilDestroyed(this.destroyRef)
)
.subscribe(...);
When the component is destroyed:
Component destroyed
│
▼
DestroyRef
│
▼
takeUntilDestroyed()
│
├── Route subscription stops
├── Form subscription stops
└── Timer subscription stops
This makes lifecycle management much simpler.
16. AsyncPipe vs takeUntilDestroyed()
This is a useful interview distinction.
AsyncPipe
Use primarily when the Observable is consumed directly by the template.
products$ =
this.productService.getProducts();
@if (products$ | async; as products) {
...
}
takeUntilDestroyed()
Use when you need an explicit subscription in TypeScript:
this.form.valueChanges
.pipe(
takeUntilDestroyed(this.destroyRef)
)
.subscribe(value => {
console.log(value);
});
Easy rule
Need value in HTML?
│
▼
AsyncPipe
Need explicit subscribe()
in TypeScript?
│
▼
takeUntilDestroyed()
This isn't an absolute rule, but it's a very useful default.
17. Memory Leak Example
Consider:
interval(1000)
.subscribe(value => {
console.log(value);
});
User navigates:
Products Component
│
▼
Subscription #1
│
▼
Navigate away
│
▼
Subscription #1 may still run
Then the component is opened again:
Products Component
│
▼
Subscription #2
If old subscriptions aren't cleaned up, repeated navigation can create unwanted active subscriptions:
Subscription #1 ─────► running
Subscription #2 ─────► running
Subscription #3 ─────► running
This can cause:
- unnecessary CPU work,
- duplicate callbacks,
- duplicate operations,
- retained objects/resources,
- unexpected application behavior.
18. Fixed with takeUntilDestroyed()
interval(1000)
.pipe(
takeUntilDestroyed(this.destroyRef)
)
.subscribe(value => {
console.log(value);
});
Now:
Component #1
│
▼
Subscription #1
│
▼
Component destroyed
│
▼
Subscription #1 stops
Component #2
│
▼
Subscription #2
The subscription lifetime follows the component lifetime.
Key Points
- A subscription leak can occur when an unnecessary long-lived subscription remains active after its owner is destroyed.
- Not every subscription requires manual cleanup; finite streams that complete naturally don't remain active forever.
AsyncPipemanages subscriptions automatically for template consumption.takeUntilDestroyed()is a convenient modern Angular approach for lifecycle-bound subscriptions in code.takeUntilDestroyed()is imported from:
@angular/core/rxjs-interop
- It works with Angular's destruction lifecycle.
DestroyRefcan be passed explicitly:
takeUntilDestroyed(this.destroyRef)
takeUntil()with adestroy$Subject is a common older/existing pattern.take(1)is useful when the stream logically requires only one emission.- Normal Angular
HttpClientrequests generally complete after the response. - Long-lived streams such as
interval(),valueChanges, event streams, and similar sources deserve particular attention.
Easy memory rule
Template Observable
│
▼
AsyncPipe
Explicit subscription
in Angular class
│
▼
takeUntilDestroyed()
Only one value needed
│
▼
take(1)
Interview Questions and Answers
1. How do you prevent RxJS subscription memory leaks in Angular?
Answer:
Use lifecycle-aware techniques such as AsyncPipe for template subscriptions and takeUntilDestroyed() for explicit subscriptions. For streams that logically need only one value, operators such as take(1) can also complete the subscription automatically.
2. What is takeUntilDestroyed()?
Answer:takeUntilDestroyed() is an Angular RxJS interop operator that completes an Observable chain when the associated Angular destruction context is destroyed.
observable$
.pipe(
takeUntilDestroyed(this.destroyRef)
)
.subscribe(...);
3. Where is takeUntilDestroyed() imported from?
import {
takeUntilDestroyed
} from '@angular/core/rxjs-interop';
4. What is DestroyRef?
Answer:DestroyRef represents Angular's destruction lifecycle for the current context and allows cleanup behavior to be associated with that lifecycle.
private destroyRef =
inject(DestroyRef);
5. Do we need ngOnDestroy() with takeUntilDestroyed()?
Answer:
Usually no. One of its main advantages is that you don't need to create a destroy$ Subject or manually implement ngOnDestroy() solely for subscription cleanup.
6. What is the difference between takeUntil() and takeUntilDestroyed()?
Answer:
takeUntil()
→ RxJS operator
→ Requires a notifier Observable
→ Often uses destroy$ + ngOnDestroy()
takeUntilDestroyed()
→ Angular RxJS interop
→ Uses Angular destruction lifecycle
→ Less lifecycle boilerplate
7. AsyncPipe vs takeUntilDestroyed()?
Answer:
Use AsyncPipe when the Observable is primarily consumed in the template. Use takeUntilDestroyed() when you need an explicit subscription in Angular TypeScript code that should stop with the component/directive lifecycle.
8. Do HttpClient subscriptions need to be manually unsubscribed?
Answer:
Normal one-shot HttpClient Observables usually complete after receiving their response, so they generally don't remain as long-lived subscriptions. Lifecycle cancellation can still be useful when an in-flight request is no longer needed.
9. Which subscriptions are more likely to require lifecycle management?
Answer:
Long-lived streams such as:
interval()
FormControl.valueChanges
Router/event streams
Subjects
WebSocket/event streams
Custom non-completing Observables
should be managed appropriately when explicitly subscribed to.
10. Short Interview Answer
To prevent subscription leaks in Angular, prefer
AsyncPipefor template-bound Observables andtakeUntilDestroyed()for lifecycle-bound explicit subscriptions.takeUntilDestroyed()ties the Observable subscription to Angular's destruction lifecycle, so it stops automatically when the component or directive is destroyed.