Subscribing & Unsubscribing
An observable is lazy — it does nothing until you subscribe. Subscribing kicks off the producer (a timer, an HTTP request, a stream of events) and registers callbacks that run as values arrive. But every active subscription holds resources, and if you never tear it down it keeps running after the component is gone — the classic Angular memory leak. This page covers how to subscribe, what the Subscription object gives you, and the modern strategies for guaranteed cleanup.
Subscribing to an observable
Calling .subscribe() activates the stream. You can pass either three positional callbacks (the legacy form) or a single observer object with next, error, and complete handlers. The object form is clearer and is the recommended style today.
import { interval } from 'rxjs';
import { take } from 'rxjs/operators';
interval(1000).pipe(take(3)).subscribe({
next: (v) => console.log('value', v),
error: (err) => console.error('failed', err),
complete: () => console.log('done'),
});
Output:
value 0
value 1
value 2
done
Each call to subscribe creates an independent execution of the observable. Subscribe twice and the producer function runs twice — two timers, two HTTP requests. This is what “cold observable” means, and it is why you should share a single subscription rather than calling subscribe repeatedly.
The Subscription object
subscribe() returns a Subscription — a handle representing the ongoing execution. Its most important method is unsubscribe(), which runs the observable’s teardown logic (clearing timers, removing listeners, aborting requests) and stops further emissions.
import { interval } from 'rxjs';
const sub = interval(500).subscribe((v) => console.log('tick', v));
// Stop after 1.2 seconds
setTimeout(() => sub.unsubscribe(), 1200);
Output:
tick 0
tick 1
Subscriptions compose. You can call sub.add(otherSub) to group children under a parent, so a single parent.unsubscribe() tears down all of them at once.
const group = new Subscription();
group.add(stream1$.subscribe(/* … */));
group.add(stream2$.subscribe(/* … */));
// Cancels both at once
group.unsubscribe();
A stream that completes or errors unsubscribes itself automatically —
of(), finitetake()pipelines, and a singleHttpClientrequest all clean up on their own. The leak risk is with infinite streams:interval,fromEvent, route params,valueChanges, andBehaviorSubject. Those need explicit teardown.
Why manual unsubscribe is fragile
Storing every subscription and unsubscribing in ngOnDestroy works, but it is noisy and easy to forget:
export class StaleComponent implements OnDestroy {
private subs: Subscription[] = [];
ngOnInit() {
this.subs.push(this.service.data$.subscribe(/* … */));
}
ngOnDestroy() {
this.subs.forEach((s) => s.unsubscribe());
}
}
One missed push, one early return, and you leak. Angular offers two far better patterns: takeUntilDestroyed and the async pipe.
takeUntilDestroyed
takeUntilDestroyed (from @angular/core/rxjs-interop) is an operator that completes the stream when the component or directive is destroyed. Called inside a constructor or field initializer it picks up the injection context automatically; elsewhere, pass a DestroyRef.
import { Component, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({
selector: 'app-clock',
standalone: true,
template: `<p>Ticks: {{ count }}</p>`,
})
export class ClockComponent {
private readonly destroyRef = inject(DestroyRef);
count = 0;
constructor() {
interval(1000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.count++);
}
}
When the component is destroyed the source completes, the subscription tears down, and you wrote zero cleanup code. This is the preferred way to subscribe imperatively in modern Angular.
The async pipe
When the value is destined for the template, skip subscribe entirely. The async pipe subscribes for you, renders the latest value, and unsubscribes when the host view is destroyed — leak-proof by construction. It pairs naturally with the new @if and @for control flow.
import { Component } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { interval, map } from 'rxjs';
@Component({
selector: 'app-async-clock',
standalone: true,
imports: [AsyncPipe],
template: `
@if (elapsed$ | async; as seconds) {
<p>Elapsed: {{ seconds }}s</p>
}
`,
})
export class AsyncClockComponent {
elapsed$ = interval(1000).pipe(map((n) => n + 1));
}
Using as seconds subscribes once and binds the result to a local variable, avoiding the multiple-subscription trap of piping the same observable several times in a template.
Choosing a strategy
| Strategy | Cleanup | Best for |
|---|---|---|
Manual unsubscribe() | You call it in ngOnDestroy | One-off non-Angular code, dynamic subscriptions |
Subscription.add() | One unsubscribe() cancels the group | Bundling several manual subscriptions |
takeUntilDestroyed() | Automatic on destroy | Imperative subscriptions inside components/directives |
async pipe | Automatic with the view | Values rendered directly in the template |
Best Practices
- Prefer the
asyncpipe whenever the value goes to the template — it never leaks and removes boilerplate. - For imperative subscriptions inside a component, reach for
takeUntilDestroyedinstead of hand-managingngOnDestroy. - Use the observer-object form of
subscribeand always handle theerrorchannel so failures are not swallowed silently. - Remember finite streams complete on their own; only infinite sources (
interval,fromEvent,valueChanges, subjects) need explicit teardown. - Avoid subscribing to the same observable multiple times in a template; bind once with
| async as value. - Never nest
subscribecalls — use flattening operators likeswitchMapinstead to keep teardown automatic.