skip to content

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%

answer

  1. stored value starts empty
  2. synchronous emission during subscribe
  3. return type widens with null
  4. strict template type checking complains
  5. reference change resets it

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.

solid answer

~40 s

`AsyncPipe` keeps a `_latestValue` that starts as `null`. On the first check `transform` subscribes and then returns that stored value. If the source emits **synchronously during the subscribe call** (a `BehaviorSubject`, `of(...)`, a stream ending in `startWith(0)`), the value is already stored and the first check shows it. An `HttpClient` call or a timer emits later, so the first check gets `null` and the view updates after `markForCheck()`. That is why the transform's return type is `T | null`: under `strictTemplates`, binding `unreadCount$ | async` to a non-nullable input is a type error. The value also drops back to `null` whenever the bound expression yields a new Observable, until that one emits.

code

ts · 16 lines
ts
import { Component } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { BehaviorSubject, timer, map } from 'rxjs';

@Component({
  selector: 'app-unread-demo',
  imports: [AsyncPipe],
  template: `
    <p>sync: {{ syncCount$ | async }}</p>          <!-- 3 on the first check -->
    <p>async: {{ (asyncCount$ | async) ?? 0 }}</p> <!-- 0, then 7 after 500 ms -->
  `,
})
export class UnreadDemo {
  readonly syncCount$ = new BehaviorSubject(3);
  readonly asyncCount$ = timer(500).pipe(map(() => 7));
}

go deeper

for a junior

Remember that the async pipe gives null until the first value arrives, and guard templates or default the value with ?? accordingly.

for a middle

Explain the stored value, the synchronous-emission case, the T | null return type under strictTemplates, and the reset to null on a new source reference.

for a senior

Choose deliberately between a null loading phase and a seeded stream, and catch non-null assertions that pass null into required inputs.

for a principal

Set a codebase convention for initial and loading states across streams and signals, so components do not each invent their own null handling.

## Where the null comes from `AsyncPipe` stores the most recent value it has received in a private field that is **initialised to `null`**. Its `transform` method always ends by returning that field. On the very first check the sequence is: 1. `transform(unreadCount$)` is called and sees it has no source yet. 2. It subscribes to `unreadCount$`. 3. It returns the stored value. Whether step 3 returns `null` depends entirely on what happened during step 2. ## Synchronous versus asynchronous sources | Source | Emits during subscribe? | First check shows | |---|---|---| | `new BehaviorSubject(3)` | yes, its current value | `3` | | `of(3)` | yes | `3` | | `source$.pipe(startWith(0))` | yes, the start value | `0` | | `http.get<number>('/api/unread')` | no, the response arrives later | `null` | | `interval(1000)` or `timer(500)` | no | `null` | | a `Promise` | no, `then` callbacks always run later | `null` | A **synchronous emission** is stored before `transform` returns, so the first check has the real value. The pipe also skips `markForCheck()` in that case: the value is going out in the same call, so asking for another pass would be wasted work. An **asynchronous emission** arrives after the check has finished. The pipe stores it, calls `markForCheck()`, and the next pass shows it. In between, the template rendered with `null`. ## Why the type is `T | null` The pipe's `transform` overloads declare `Observable<T> -> T | null`, and `null` or `undefined` input returns `null`. With **strict template type checking** (`strictTemplates` in `angularCompilerOptions`) the compiler checks bindings against that type: - `[count]="unreadCount$ | async"` into `count = input.required<number>()` fails, because `number | null` is not assignable to `number`. - `{{ unreadCount$ | async }}` in text is fine: interpolation renders `null` as an empty string. - Property access needs a guard: `(user$ | async)?.name` or an `@if` block. The non-null assertion `(unreadCount$ | async)!` silences the checker but passes a real `null` into the child on the first check, so it hides a bug rather than fixing one. ## The null also returns later When the expression yields a **different Observable reference** (for example a field reassigned when an input changes), the pipe disposes the old subscription and **resets its stored value to `null`** before subscribing to the new source. A badge that showed `5` can therefore blank out until the new source emits. Keeping the old value visible has to be designed into the stream, for example one long-lived Observable that switches internally, rather than swapping Observable references in the template. ## Ways to deal with it - **Give the stream a synchronous first value**: `startWith(0)`, or a `BehaviorSubject` seeded with a sensible default. - **Default in the template**: `{{ (unreadCount$ | async) ?? 0 }}`. - **Guard a block**: render the section only once a value exists, with an `@if` over the piped value (mind that `0` is falsy). - **Convert to a signal with an initial value**: `toSignal()` accepts an `initialValue` option, which is a separate topic. None of these is universally right. A loading indicator often wants the `null` phase to exist; a counter usually wants `0` from the start. ## What interviewers listen for - The stored value **starts as `null`**, not `undefined`. - **Synchronous** sources show a value on the first check; HTTP, timers and Promises do not. - The return type is **`T | null`**, which strict templates enforce. - A **new source reference resets** the value to `null`.

  • Why does the async pipe not call markForCheck for a value emitted synchronously during subscribe?
    That value is stored before `transform` returns, so the current check already renders it. Marking the view again would only schedule a redundant pass, so the pipe suppresses `markForCheck` while its own subscribe call is running and enables it for later emissions.
  • Is `(count$ | async)!` a good fix for a strict-template error on a required number input?
    No. It removes the compile error but the child still receives `null` on the first check whenever the source is asynchronous. Seed the stream with `startWith`, default with `??`, or guard the child with `@if` so the type and the runtime value agree.

saying these in an interview costs you the question

  • The async pipe returns undefined before the first emission
  • A BehaviorSubject bound with the async pipe also shows null first
  • The non-null assertion makes the first value safe
  • The async pipe keeps showing the old value when the Observable reference changes
  • Interpolating null from the async pipe prints the word null