skip to content

Async Pipe Mechanics

The async pipe subscribes with the view, marks it for check on each emission and unsubscribes on destroy. Interviewers ask about the null first value, duplicate requests and @if aliasing.

part ofAngularoverview, primer and where to startread it →
on this pageshow

explore

questions

6

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
open as a page

An Angular header pipes `notifications$ = http.get(...)` through `| async` for a badge and again for a list; why does the Network tab show two requests, and how do you fix it?

level: seniorimportance: must knowfreq 62%

basics

~10 s

Each | async in an Angular template is its own AsyncPipe subscription, and an HttpClient Observable sends a new request per subscription, so two bindings send two requests; subscribe once and reuse the value.

open as a page

In an Angular template, what does `@if (unread$ | async; as count)` give you, and why can it hide a legitimate zero?

level: middleimportance: should knowfreq 52%

basics

~20 s

Angular's @if (unread$ | async; as count) subscribes once and exposes the value as count inside the block, but the block renders only for truthy values, so null before loading and a real 0 both hide it.

open as a page

In Angular, why does `unreadCount$ | async` evaluate to null on the first check, and when does it return a real value immediately?

level: middleimportance: should knowfreq 58%

basics

~20 s

Angular's async pipe returns its stored latest value, which starts as null; it becomes real on the first pass only if the source emits synchronously while being subscribed, as a BehaviorSubject, of() or startWith() source does.

open as a page

In Angular, why does binding `loadNotifications() | async`, where the method returns `this.http.get(...)`, send request after request?

level: seniorimportance: should knowfreq 48%

basics

~20 s

Angular calls the method on every change-detection pass, each call returns a new Observable, and the async pipe treats a new reference as a new source, so it unsubscribes, resubscribes and sends another request each time.

open as a page

In Angular, what happens when an Observable bound with the async pipe errors, and how do you render an error state instead?

level: middleimportance: nice to knowfreq 35%

basics

~20 s

Angular's async pipe reports the error to the application's ErrorHandler; the errored subscription is over and the binding keeps its last value or null, so render an error state by catching the error in the stream and mapping it to a value.

open as a page