skip to content

In Angular, why does calling HttpClient.get() without subscribing send no request, and why does subscribing to the result twice send two?

level: juniorimportance: must knowfreq 62%

answer

  1. the call builds, subscribe runs
  2. interceptors rerun per subscription
  3. one body, then complete
  4. unsubscribe aborts the request
  5. sharing needs an operator

basics

~20 s

HttpClient methods return cold Observables: the call only describes the request, and each subscription runs the interceptor chain and backend again, sending a new request. With the default observe: 'body' each run emits one body and completes.

solid answer

~40 s

`HttpClient.get()` returns a **cold** Observable built as `of(request)` piped into the handler chain, so the interceptors and the backend only run when someone subscribes. No `subscribe()`, no async pipe, no `toSignal()` means no request. Every subscription runs the chain again, so two subscriptions — two `async` pipes on the same Observable, or a `subscribe()` plus an async pipe — send two requests, and a `retry` re-runs the interceptors too. With the default `observe: 'body'`, each run emits the response body once and completes; a failure arrives as an `HttpErrorResponse` error. Unsubscribing before the response aborts the request; in Angular 22 the default backend is `FetchBackend`, which aborts through an `AbortController`. To share one response, you add a multicasting operator or cache it in a service.

code

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

interface Product {
  id: string;
  name: string;
  price: number;
}

@Component({
  selector: 'app-product-summary',
  imports: [AsyncPipe],
  template: `
    <!-- Two async pipes = two subscriptions = two GET requests -->
    <h1>{{ (product$ | async)?.name }}</h1>
    <p>{{ (product$ | async)?.price }}</p>

    <!-- One subscription, reused inside the block -->
    @if (product$ | async; as product) {
      <h1>{{ product.name }}</h1>
      <p>{{ product.price }}</p>
    }
  `,
})
export class ProductSummary {
  private readonly http = inject(HttpClient);
  readonly product$ = this.http.get<Product>('/api/products/42');
}

go deeper

for a junior

Recall that nothing is sent until something subscribes, and that each subscription sends its own request.

for a middle

Explain how HttpClient builds the Observable, what the observe option changes about emissions, and why the stream completes.

for a senior

Spot duplicate requests from repeated subscriptions, use unsubscription to cancel, and decide where a response should be shared.

for a principal

Set conventions for where HTTP Observables are subscribed and shared, so screens do not multiply requests as they grow.

## The symptom Two classic support questions come from the same fact. A developer writes `this.http.post('/api/cart', item);` in a click handler and nothing reaches the server. Another binds `product$ | async` twice in a product-detail template and sees two identical `GET /api/products/42` requests in the network panel. Both follow from HttpClient returning **cold** Observables. This answer assumes Angular 22.2 and RxJS 7.8. ## How HttpClient builds the Observable Inside `HttpClient.request()`, which every shortcut method (`get`, `post`, `put`, …) calls, Angular: 1. Creates an `HttpRequest` object from the URL and options. 2. Builds `of(request).pipe(concatMap((req) => handler.handle(req)))`, where the handler is the interceptor chain ending in the backend. 3. For the default `observe: 'body'`, filters the event stream down to the `HttpResponse` and maps it to its body. Nothing in these steps touches the network. The source comment spells out the intent: running the handler inside the Observable chain means interceptors are **re-run on every subscription**, and retries re-run them too. ## What subscribing does | Action | Result | | --- | --- | | Call `http.get(url)` and drop the result | No request | | Subscribe once | One request; one body emitted; completes | | Subscribe twice (two `async` pipes, or two `subscribe()` calls) | Two independent requests | | Unsubscribe before the response | Request aborted | | `retry(2)` on a failing call | Up to three requests, each through the interceptors | ## Emissions and completion - With `observe: 'body'` (the default) the Observable emits **one** value, the parsed body, then **completes**. - With `observe: 'response'` it emits one `HttpResponse`, including status and headers, then completes. - With `observe: 'events'` it emits several `HttpEvent` values — a sent event, header and progress events when `reportProgress` is set, and the final response — then completes. - A non-2xx status or network failure arrives as an **error** notification carrying an `HttpErrorResponse`; no completion follows. Because the Observable always terminates, a subscription to it cannot stay open indefinitely. Unsubscribing early is still useful, because it **cancels**: in Angular 22 the default backend is `FetchBackend`, whose teardown calls `abort()` on its `AbortController`; with `provideHttpClient(withXhr())` the XHR backend calls `xhr.abort()`. ## Fixing the two symptoms ```ts // Nothing sent: the Observable was never subscribed. save(item: CartItem): void { this.http.post('/api/cart', item); } // Sent: subscribing runs the request. save(item: CartItem): void { this.http.post('/api/cart', item).subscribe(); } ``` For the duplicate `GET`, subscribe once. Options include one `@if (product$ | async; as product)` block that the rest of the template reads from, converting it once with `toSignal()`, or sharing it in a service. Each of those tools has its own trade-offs, covered in their own topics. ## Why cold is the right default - A request is a side effect; making it explicit on subscription means building an Observable never has hidden cost. - Each subscriber gets its own fresh response, so an Observable stored in a field can be re-subscribed later to refetch. - Retrying is just re-subscribing, and interceptors see every attempt — useful for auth headers and logging. ## Things to remember - Calling the method is not sending the request. - One subscription, one request. - Completion is automatic; cancellation is by unsubscribing.

  • Do you need to unsubscribe from an HttpClient Observable to avoid a leak?
    Not for leaks: the Observable completes after the response or errors, which ends the subscription. You unsubscribe when you want to cancel an in-flight request, for example when the user leaves the page, and unsubscribing then aborts it.
  • Why does a retry re-run your auth interceptor?
    Retrying re-subscribes to the source, and HttpClient's Observable runs the whole handler chain, interceptors included, on every subscription. Each attempt therefore goes through the interceptors again, which lets them refresh headers per attempt.

saying these in an interview costs you the question

  • Calling http.get() sends the request immediately.
  • Two async pipes on one HttpClient Observable share one request.
  • An HttpClient Observable stays open until the component is destroyed.
  • Unsubscribing from an HTTP call only ignores the response.
  • HttpClient errors are delivered as a normal next value.