skip to content

In Angular, what does the async pipe do over a component's lifetime when its template binds `notifications$ | async`?

level: juniorimportance: must knowfreq 80%

answer

  1. starts on first evaluation, not construction
  2. latest value, returned synchronously
  3. markForCheck on each async emission
  4. new reference means dispose and resubscribe
  5. ngOnDestroy unsubscribes

basics

~20 s

Angular's async pipe subscribes to the Observable or Promise when the binding is first evaluated, returns its latest value, calls markForCheck on each asynchronous emission, swaps subscriptions if the reference changes, and unsubscribes when the view is destroyed.

solid answer

~40 s

`AsyncPipe` (from `@angular/common`, imported into a standalone component's `imports`) is an impure pipe (`pure: false`) whose `transform` runs on every check. The first time it sees a non-null Observable or Promise it subscribes and returns the latest value it holds, which is `null` until something arrives. Each asynchronous emission stores the value and calls `ChangeDetectorRef.markForCheck()`, so the view is refreshed even under `OnPush` (the default for new components since v22) and in a zoneless app. If the expression later yields a different reference, the pipe disposes the old subscription and subscribes to the new one. When the view is destroyed, `ngOnDestroy` unsubscribes, so the component writes no teardown code for that stream.

code

ts · 20 lines
ts
import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';

interface Notification { id: number; text: string; }

@Component({
  selector: 'app-notification-feed',
  imports: [AsyncPipe],
  template: `
    @for (n of notifications$ | async; track n.id) {
      <p>{{ n.text }}</p>
    }
  `,
})
export class NotificationFeed {
  private http = inject(HttpClient);
  // Created once; the async pipe subscribes on first check and unsubscribes on destroy.
  readonly notifications$ = this.http.get<Notification[]>('/api/notifications');
}

go deeper

for a junior

Recall the three jobs: subscribe, return the latest value, unsubscribe when the view goes away. Remember to import AsyncPipe into a standalone component.

for a middle

Explain the impure transform, markForCheck on asynchronous emissions only, the reset to null when the bound reference changes, and why each | async is its own subscription.

for a senior

Tie the pipe to OnPush-by-default and zoneless applications, and spot where a template's pipes multiply subscriptions or never subscribe because their block never renders.

for a principal

Weigh template-owned subscriptions against toSignal-based components for a codebase's conventions, considering migration cost, testability and consistency across teams.

## What the async pipe is `AsyncPipe` is a built-in pipe exported from `@angular/common` under the template name `async`. In a standalone component you add `AsyncPipe` to the component's `imports` array (importing `CommonModule` also works). It accepts an `Observable`, any **Subscribable** (an object with a `subscribe` method), a `Promise` or `PromiseLike`, or `null`/`undefined`. Anything else throws an `InvalidPipeArgument` runtime error. The pipe is declared with `pure: false`. An **impure pipe** has its `transform` method called on every change-detection pass of the view that contains it, not only when its input reference changes. That is what lets it hand back a value that changed inside the stream while the stream object itself stayed the same. Every place in a template that writes `| async` gets **its own pipe instance**. Two bindings mean two instances and two subscriptions, which is the root of the duplicate-request questions interviewers like to ask. ## The lifecycle, step by step 1. **Creation.** The pipe instance is created with the view. It holds no subscription yet, and its stored latest value is `null`. 2. **First evaluation.** During the view's first check, `transform(notifications$)` sees it has no source, subscribes, and returns whatever the latest value is at that moment. For a source that emits synchronously on subscribe (a `BehaviorSubject`, `of(...)`, a `startWith`), that is already the real value; for an HTTP call or a timer it is `null`. 3. **Asynchronous emission.** When the source emits later, the pipe stores the value and calls `markForCheck()` on the view's `ChangeDetectorRef`. That marks the view and its ancestors dirty and notifies Angular's change-detection scheduler, so the next pass calls `transform` again and it returns the new value. 4. **Reference change.** If a later check passes a *different* Observable or Promise, the pipe disposes the old subscription, resets the stored value to `null`, and subscribes to the new source. 5. **Destruction.** When the view is destroyed (the component is removed, an `@if` block turns false, an `@for` row disappears), `ngOnDestroy` unsubscribes and drops its reference to the `ChangeDetectorRef`. Two details are easy to miss. A value delivered **synchronously during the subscribe call** does not trigger `markForCheck`, because it is returned from the same `transform` call anyway. And a binding inside a block that never renders never subscribes at all: the pipe lives in that block's view. ## Why the markForCheck matters in current Angular Since v22, a component that does not set `changeDetection` is `OnPush`, and since v21 new applications are zoneless by default. In that world nothing re-checks a view "just because" an HTTP response arrived. The async pipe's `markForCheck()` is exactly the notification Angular needs: it marks the view for check and schedules a pass. A component that subscribed manually and assigned the value to a plain field would stay stale under those defaults unless it also called `markForCheck()` or stored the value in a signal. ## Promises are handled too For a `Promise`, the pipe attaches `then` callbacks. A Promise cannot be cancelled, so on destroy the pipe simply **drops its callbacks**; if the Promise resolves later, the result is ignored and the destroyed view is not touched. ## Compared with subscribing in the class | Concern | `obs$ \| async` in the template | `subscribe()` in the class | |---|---|---| | Subscribes | on first evaluation of the binding | wherever you call it | | Marks the view for check | automatically, per async emission | you call `markForCheck()` or write a signal | | Unsubscribes | on view destroy, or when the reference changes | you arrange it yourself | | Value before the first emission | `null` | whatever you initialised the field to | | Errors | sent to the application's `ErrorHandler` | your `error` callback | ## What interviewers listen for - That the pipe is **impure** and runs on every check, but only subscribes once per source reference. - That it **marks for check**, which is why it works with `OnPush` and zoneless apps without extra code. - That it **unsubscribes on destroy** and when the bound reference changes. - That each `| async` is a **separate subscription**, so a cold source such as an `HttpClient` call runs once per binding. - That the first value is often **`null`**, and the template has to allow for it. In new code many teams convert streams to signals with `toSignal()` instead, but the async pipe remains fully supported and is everywhere in existing codebases.

  • Does the async pipe subscribe when the component is constructed?
    No. It subscribes inside `transform`, during the first change-detection pass that evaluates the binding. A binding inside an `@if` or `@defer` block that has not rendered has no pipe instance yet, so nothing subscribes until that block's view is created.
  • Why does the async pipe work in an `OnPush` component without extra code?
    On each asynchronous emission it calls `markForCheck()` on the view's `ChangeDetectorRef`, which marks the view and its ancestors dirty and notifies the change-detection scheduler, so the next pass re-reads the binding. Values delivered synchronously during subscribe are returned directly without marking.
  • What does the async pipe do with a Promise when the view is destroyed first?
    It cannot cancel the Promise, so it discards the callbacks it attached. When the Promise settles later, the result is ignored and the destroyed view is never updated.

It is like a newspaper subscription tied to a flat's lease: it starts when someone moves in (the view renders), each delivery rings the doorbell so the resident looks (markForCheck), and moving out cancels it automatically (the view is destroyed).

saying these in an interview costs you the question

  • The async pipe subscribes in the component constructor
  • The async pipe is pure, so it only runs when the input reference changes
  • You still need takeUntilDestroyed for streams bound with the async pipe
  • The async pipe only works with Default or Eager change detection
  • Two async pipes on the same Observable share one subscription