Async Pipe
The async pipe is one of Angular’s most useful built-in pipes. It subscribes to an Observable or Promise directly in the template, returns the latest emitted value, and—crucially—unsubscribes automatically when the component is destroyed. This eliminates an entire class of memory-leak bugs that come from manual subscribe() calls and forgotten teardown logic, while keeping your component classes thin and declarative.
How the async pipe works
When you write expression | async, Angular subscribes to the source on your behalf. Each time the source emits, the pipe marks the component for change detection and unwraps the new value into the view. When the component is destroyed—or when the expression reference changes—the pipe unsubscribes from the old source and subscribes to the new one. You never write .subscribe() and you never write a corresponding unsubscribe().
import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
interface User {
id: number;
name: string;
}
@Component({
selector: 'app-user-list',
standalone: true,
imports: [AsyncPipe],
template: `
@if (users$ | async; as users) {
<ul>
@for (user of users; track user.id) {
<li>{{ user.name }}</li>
}
</ul>
} @else {
<p>Loading users…</p>
}
`,
})
export class UserListComponent {
private http = inject(HttpClient);
users$: Observable<User[]> = this.http.get<User[]>('/api/users');
}
The as users syntax captures the unwrapped value into a template variable so you can reference it multiple times without re-subscribing. The @else branch renders while the value is still null (the initial state before the first emission).
Tip: Subscribe to a source only once per template. If you write
{{ data$ | async }}in several places, each usage creates its own subscription—and for cold observables likeHttpClient.get(), that means duplicate HTTP requests. Use@if (data$ | async; as data)and reusedata.
Why it prevents memory leaks
A manual subscription that is never torn down keeps the component (and everything it references) alive after Angular tries to destroy it. The async pipe ties the subscription lifecycle to the view, so teardown is guaranteed.
// Manual approach — easy to leak, more boilerplate
export class TimerComponent implements OnInit, OnDestroy {
count = 0;
private sub?: Subscription;
ngOnInit() {
this.sub = interval(1000).subscribe((n) => (this.count = n));
}
ngOnDestroy() {
this.sub?.unsubscribe(); // forget this line → leak
}
}
// Async pipe approach — no OnDestroy, no Subscription field
@Component({
selector: 'app-timer',
standalone: true,
imports: [AsyncPipe],
template: `<p>Ticks: {{ count$ | async }}</p>`,
})
export class TimerComponent {
count$ = interval(1000);
}
Observables vs. promises
The async pipe transparently handles both. The behavioral differences are summarized below.
| Aspect | Observable | Promise |
|---|---|---|
| Emissions | Zero or many over time | Exactly one resolution |
| Initial value | null until first emit | null until resolved |
| Unsubscribe | Pipe unsubscribes on destroy | Nothing to unsubscribe |
| Re-subscription | On reference change | On reference change |
@Component({
selector: 'app-greeting',
standalone: true,
imports: [AsyncPipe],
template: `<h2>{{ greeting | async }}</h2>`,
})
export class GreetingComponent {
greeting: Promise<string> = Promise.resolve('Hello from a promise!');
}
Combining with the signals world
In modern Angular you can bridge RxJS and signals with toSignal(), which removes the need for the async pipe entirely in many cases. The async pipe remains the right tool when you are working directly with observable streams in a template.
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-status',
standalone: true,
template: `<p>Online users: {{ count() ?? 0 }}</p>`,
})
export class StatusComponent {
private http = inject(HttpClient);
count = toSignal(this.http.get<number>('/api/online-count'));
}
Output:
Online users: 42
Gotcha: Do not call a method that creates an observable inside the template (e.g.
{{ getUsers() | async }}). Change detection runs the method repeatedly, producing a brand-new observable each cycle, which the pipe re-subscribes to endlessly. Assign the observable to a field once.
Error handling
The async pipe does not catch errors—an errored stream throws and breaks the binding. Handle errors inside the stream with catchError so the pipe always receives a valid value.
users$ = this.http.get<User[]>('/api/users').pipe(
catchError(() => of([])) // fall back to an empty list
);
Best Practices
- Prefer the async pipe over manual
subscribe()/unsubscribe()to guarantee teardown and reduce boilerplate. - Subscribe once with
@if (source$ | async; as value)and reusevalueinstead of piping the same source repeatedly. - Add
catchError(or similar) to streams so a failed emission does not break the template binding. - Never invoke a method that builds an observable inside the template—assign the stream to a class field.
- Use
shareReplay(1)on cold HTTP observables when the same data must feed several template branches. - Reach for
toSignal()when you want signal-based reactivity instead of pipe-based unwrapping.