In Angular, why does binding `loadNotifications() | async`, where the method returns `this.http.get(...)`, send request after request?
answer
- method runs every check
- new reference each time
- dispose, resubscribe, return null
- response schedules the next check
- create the Observable once
basics
~20 sAngular 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.
solid answer
~40 sThe template calls `loadNotifications()` on every check, and each call builds a **new** `HttpClient` Observable. `AsyncPipe` compares the argument with the source it holds by reference; a different reference makes it dispose the old subscription (aborting that request if it is still in flight), reset its value to `null`, and subscribe to the new one, which sends a fresh request. When that response arrives the pipe calls `markForCheck()`, the next pass calls the method again, and the cycle repeats. A getter that returns `this.http.get(...)` has the same bug. The fix is to create the Observable **once**, in a field, and rebuild it only when its inputs really change, for example by reassigning the field when a signal input changes.
code
ts · 16 linesimport { 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-count',
imports: [AsyncPipe],
template: `{{ (notifications$ | async)?.length ?? 0 }}`,
})
export class NotificationCount {
// Created once when the component is constructed: the reference never changes,
// so the async pipe subscribes once and sends one request.
readonly notifications$ = inject(HttpClient).get<Notification[]>('/api/notifications');
}go deeper
Bind the async pipe to a field holding the Observable, not to a method or getter that creates one.
Walk through the reference comparison in transform: dispose, reset to null, resubscribe, and the markForCheck that schedules the next pass.
Recognise the pattern from a Network tab full of repeated or cancelled requests, and design input-driven streams whose reference changes only when the input does.
Encode the rule in lint or review guidelines so templates never create Observables, and pick one standard way to derive requests from inputs.
## The broken binding ```ts @Component({ imports: [AsyncPipe], template: `{{ (loadNotifications() | async)?.length }}`, }) export class NotificationCount { private http = inject(HttpClient); loadNotifications() { return this.http.get<Notification[]>('/api/notifications'); } } ``` The Network tab fills with `/api/notifications` requests, and the count flickers between empty and a number. ## Why it loops The mechanism is inside `AsyncPipe.transform`: 1. Change detection evaluates the binding, so `loadNotifications()` runs and returns **a new Observable object**. 2. The pipe compares it by reference with the source it holds. It is different, so the pipe **disposes** the old subscription, **resets** its stored value to `null` and **subscribes** to the new Observable. 3. Subscribing to an `HttpClient` Observable sends a request. The binding renders `null` for now. 4. The response arrives; the pipe stores it and calls **`markForCheck()`**, which schedules another pass. 5. That pass calls `loadNotifications()` again and gets yet another new Observable, so the pipe goes back to step 2. The pipe's own `markForCheck()` feeds the loop, so it continues under `OnPush` and in a zoneless app, not only in zone-based apps where any event triggers a check. When a pass arrives while a request is still in flight, the dispose in step 2 **unsubscribes**, and unsubscribing from an `HttpClient` Observable **aborts** the request, so requests can also appear as cancelled in the Network tab. ## Variants of the same bug - A **getter**: `get notifications$() { return this.http.get(...); }` bound as `notifications$ | async`. It looks like a field but runs on every check. - An **inline operator chain**: a method that returns `this.source$.pipe(map(...))`. Even without HTTP, each call creates a new Observable, so the pipe resubscribes every pass and the value flashes to `null`. ## The fix Create the Observable once and keep the reference stable: | Situation | Stable approach | |---|---| | The request never changes | a `readonly` field initialised once | | The request depends on an input | rebuild the field only when the input changes | | The request depends on user events | one long-lived stream driven by a subject or signal, switching internally | Angular's own `HttpClient` guide shows the input-driven case: an `effect()` reassigns `user$` whenever the `userId` signal input changes, so the reference changes only when it should. The async pipe then switches from the old source to the new one, and if the old request is still in flight it is aborted by the unsubscribe. The `toObservable` and `rxResource` alternatives for input-driven requests are covered in their own topics. ## Why it is worth knowing the mechanism - It explains the **flicker**: every new reference resets the stored value to `null`. - It explains **cancelled requests**: switching sources unsubscribes, which aborts in-flight HTTP. - It explains why the same code can look harmless in a quiet page and explode on a busy one: every extra check produces another request. ## What interviewers listen for - Template expressions, methods and getters run **on every check**. - The async pipe tracks its source **by reference**; a new reference means **dispose, reset to `null`, resubscribe**. - The pipe's own **`markForCheck()`** keeps the loop alive. - The fix is a **stable reference**, rebuilt only when inputs change.
- Would ChangeDetectionStrategy.OnPush stop this loop?No. The pipe calls `markForCheck()` when each response arrives, which marks the OnPush view dirty and schedules another pass, and that pass calls the method again. OnPush only stops the loop being triggered by unrelated events; the stable reference is the real fix.
- What does the async pipe do with the previous request when the bound Observable reference changes mid-flight?It unsubscribes from the old Observable before subscribing to the new one. For an `HttpClient` Observable, unsubscribing aborts the in-progress request, so the stale response never reaches the template.
saying these in an interview costs you the question
- The async pipe caches the result of a method call between checks
- A getter is evaluated once, like a field
- OnPush alone stops the repeated requests
- Adding take(1) to the HTTP call fixes the loop
- The pipe keeps the old subscription alive when the reference changes