skip to content

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%

answer

  1. one pipe instance per binding
  2. cold source, one run per subscriber
  3. HttpClient does not deduplicate
  4. subscribe once, read many
  5. sharing across components is the service's job

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.

solid answer

~40 s

Every `| async` creates a separate `AsyncPipe` instance, and each instance subscribes on its own. An `HttpClient` Observable is cold: each subscription dispatches its own backend request, and Angular does not deduplicate them. So a badge and a list piping the same `notifications$` send two identical GETs, and a list inside an `@if (open())` panel sends another one every time the panel opens. The fix is to subscribe once and read the value many times: `@let notifications = notifications$ | async;` at the top of the template, one `@if (...; as list)` wrapping both, or `toSignal()` in the class and reading the signal twice. When the badge and list live in different components, share the stream in the service instead, for example with `shareReplay`, whose own caveats are a separate topic.

code

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

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

@Component({
  selector: 'app-header-notifications',
  imports: [AsyncPipe],
  template: `
    @let notifications = notifications$ | async;
    <button (click)="panelOpen.set(!panelOpen())">
      Notifications <span class="badge">{{ notifications?.length ?? 0 }}</span>
    </button>
    @if (panelOpen()) {
      @if (notifications) {
        @for (n of notifications; track n.id) {
          <p>{{ n.text }}</p>
        } @empty {
          <p>No notifications</p>
        }
      } @else {
        <p>Loading...</p>
      }
    }
  `,
})
export class HeaderNotifications {
  readonly panelOpen = signal(false);
  readonly notifications$ = inject(HttpClient).get<Notification[]>('/api/notifications');
}

go deeper

for a junior

Remember that each | async subscribes separately, so piping one HTTP Observable twice sends two requests.

for a middle

Explain pipe instances per usage site, cold HttpClient Observables and the @let or single-alias fix inside one template.

for a senior

Diagnose duplicates from the Network tab, including blocks that resubscribe when recreated, and decide when sharing must move into a service instead of the template.

for a principal

Set the rule for where HTTP-backed state is owned and shared, weighing refetch-on-open freshness against request volume and consistency between widgets.

## The scenario A header component shows a notifications badge and a dropdown list: ```html <span class="badge">{{ (notifications$ | async)?.length }}</span> @if (panelOpen()) { @for (n of notifications$ | async; track n.id) { <app-notification-row [item]="n" /> } } ``` with `notifications$ = inject(HttpClient).get<Notification[]>('/api/notifications')`. The Network tab shows `/api/notifications` twice on page load (when the panel starts open) and once more every time the panel opens. ## Why it happens Two facts combine: 1. **Each `| async` is its own pipe instance.** Angular creates a pipe instance per usage site in the template. Each `AsyncPipe` instance keeps its own subscription and its own latest value; instances do not look for another pipe already subscribed to the same Observable. 2. **An `HttpClient` Observable is cold.** No request is sent until someone subscribes, and every subscription sends its own request. Angular's documentation states that subscribing to the same `HttpClient` Observable several times triggers several backend requests, each independent. So two pipes mean two requests. The list inside `@if (panelOpen())` is worse: its pipe lives in the block's embedded view, so closing the panel destroys the view (and unsubscribes) and opening it creates a new view with a **new pipe and a new request**. Whether that refetch is a bug or a feature depends on how fresh the list must be, but it should be a decision rather than an accident. The duplicate is also a consistency risk: the badge and the list are filled by two different responses and can disagree for a moment. ## Fixes inside one template The rule is **subscribe once, read many times**. - **`@let` at the top** (stable since v19): ```html @let notifications = notifications$ | async; <span class="badge">{{ notifications?.length }}</span> @if (panelOpen()) { @for (n of notifications; track n.id) { <app-notification-row [item]="n" /> } } ``` One pipe, one request, and opening the panel reuses the value already loaded. - **One `@if` alias around both** consumers. This also gives one subscription, but the whole section hides while the value is null. - **`toSignal()` in the class** subscribes once and exposes a signal the template reads in as many places as it likes. Its options and injection-context rules are covered elsewhere. ## Fixes across components If the badge is in the header and the list is a separate component, each component's template subscribes on its own, and no template trick can merge them. Then the stream must be **shared in the service** both inject: a multicasting operator such as `shareReplay` or a subject-backed store. Those designs, and the `refCount` trap of `shareReplay`, belong to observable data services, not to the async pipe. ## How to diagnose it | Symptom | Likely cause | |---|---| | N identical requests on load | N `\| async` bindings on one cold Observable | | A request each time a panel opens | the binding sits in an `@if`/`@defer` block that is recreated | | A request on every change-detection pass | the bound expression returns a new Observable each check | | Requests from several components | each component subscribes; share in the service | Count the `| async` occurrences of the stream in the template and check whether any sit inside blocks that come and go. ## What interviewers listen for - **One pipe instance, one subscription**, per `| async`. - `HttpClient` Observables are **cold**; Angular does not deduplicate requests. - **Blocks that are recreated** resubscribe. - The template fix (**`@let`**, one alias, or `toSignal`) versus the **service-level** fix when components differ.

  • Does ChangeDetectionStrategy.OnPush reduce the number of requests here?
    No. OnPush controls when a view is checked, not how many pipes subscribe. Two `| async` bindings still create two subscriptions and two requests; only subscribing once, in the template or in a service, removes the duplicate.
  • The badge and the list are separate components; which fix still works?
    Only sharing at the source. Each component's template subscribes independently, so move the stream into a service both inject and multicast it there, for example with a subject-backed store or a replaying share operator, then let each component read the shared stream.
  • Why can two duplicate requests make the badge and list disagree?
    Each subscription gets its own response. If the data changes between the two requests, the badge count and the list render from different snapshots until something refreshes, which looks like a bug to users.

Two televisions in one house, each with its own cable subscription: both show the same channel, but the provider bills twice. Run one cable to a splitter (one subscription) and feed both screens from it.

saying these in an interview costs you the question

  • Angular deduplicates identical HttpClient GET requests automatically
  • Two async pipes on the same Observable reference share one subscription
  • OnPush change detection prevents the duplicate requests
  • A block that is hidden and shown again reuses its old pipe subscription
  • The fix is to add take(1) to the HTTP call