How do you make an Angular service load a feature-flag config through HttpClient once and share the cached response with every component?
answer
- HttpClient observables are cold
- multicast with a replay buffer
- a field, not a method
- what happens after an error
basics
~20 sStore http.get(url).pipe(shareReplay(1)) in a service field so every caller gets the same shared Observable: the first subscriber fires one request, later subscribers receive the replayed response, and a failed request is retried by the next subscriber.
solid answer
~40 sAn `HttpClient` call returns a cold Observable, so each subscription sends its own request. To load flags once, the service keeps one shared Observable in a readonly field: `readonly flags$ = this.http.get<Flags>('/api/flags').pipe(shareReplay(1))`. The first subscriber triggers the request; the response goes into `shareReplay`'s internal `ReplaySubject` and is replayed to every later subscriber. Because the request completes and `shareReplay` does not reset on completion, the value stays cached for as long as the service lives, which for a root service is the whole app. An error does reset it, so the next subscriber retries instead of receiving a cached failure. The common bug is returning `this.http.get(...).pipe(shareReplay(1))` from a method: every call builds a new cache and a new request.
code
ts · 19 linesimport { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, map, of, shareReplay } from 'rxjs';
export interface Flags { [name: string]: boolean }
@Injectable({ providedIn: 'root' })
export class FeatureFlagService {
private readonly http = inject(HttpClient);
readonly flags$ = this.http.get<Flags>('/api/flags').pipe(
catchError(() => of<Flags>({})),
shareReplay(1),
);
isOn$(name: string) {
return this.flags$.pipe(map(f => f[name] ?? false));
}
}go deeper
Remember that each subscription to an HttpClient call sends a request, and that shareReplay(1) in a service field shares one response.
Explain the field-versus-method trap, why the value stays cached after completion, and that errors reset the cache so the next subscriber retries.
Decide where catchError sits, how the cache is refreshed after login, and whether flags belong in a bootstrap initializer instead of a lazy cache.
Weigh a lazily shared request against bootstrap loading: startup latency, offline behaviour, and whether every route can tolerate flags arriving late.
## The problem: HttpClient is cold Angular's `HttpClient` methods return **cold** Observables. Nothing is sent when `get()` is called; the request goes out when something subscribes, and **each** subscription sends its own request. A feature-flag config read by a header, a guard and three feature components therefore turns into five identical requests unless something shares the result. ## The fix: one shared Observable in a field ```ts import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { shareReplay } from 'rxjs'; export interface Flags { [name: string]: boolean } @Injectable({ providedIn: 'root' }) export class FeatureFlagService { private readonly http = inject(HttpClient); readonly flags$ = this.http .get<Flags>('/api/flags') .pipe(shareReplay(1)); } ``` `shareReplay(1)` turns the cold request into a shared one: 1. The **first** subscriber connects the source: one HTTP request is sent. 2. Values from the source are multicast through an internal `ReplaySubject` with a buffer of **1**. 3. **Later** subscribers, even ones arriving after the response, receive the buffered response from that subject without a new request. 4. When the request **completes**, `shareReplay` keeps the completed subject. In RxJS 7.8 it is built on `share()` with `resetOnComplete: false`, so the cached value is kept for the life of the Observable. Because the service is a root singleton, the field is created once, and so is the cache. ## The method trap ```ts // Bug: a new cache on every call getFlags() { return this.http.get<Flags>('/api/flags').pipe(shareReplay(1)); } ``` Each call builds a brand-new Observable with its own `ReplaySubject`, so three callers still produce three requests. The cache must be created **once** and reused: a field, or a lazily assigned private variable that a method returns. ## Errors are not cached `shareReplay` is configured with `resetOnError: true`. If the request fails, the error is delivered to the current subscribers and the internal subject is discarded. The **next** subscriber starts a fresh request. That is usually what you want for configuration: a transient failure at startup does not poison the app until reload. It also means a component that subscribes in a loop after errors will send a request each time, so pair it with a deliberate `retry` or a fallback such as `catchError(() => of(defaultFlags))` placed **before** `shareReplay` if the default should itself be cached. ## Choosing the buffer and the arguments | Call | Behaviour for a single HTTP response | |---|---| | `shareReplay(1)` | caches the one response; the usual choice | | `shareReplay()` | unbounded buffer; same result for one response, but unclear intent | | `shareReplay({ bufferSize: 1, refCount: true })` | cached after completion as well; cancels the request only if every subscriber leaves before it completes | For a request that emits once and completes, the `refCount` flag matters only while the request is in flight. ## Refreshing and loading before first render A `shareReplay` cache over a completed request never refreshes by itself. When flags must be reloaded (after login, on a timer), drive the request from a trigger, for example a `Subject` fed into `switchMap`, and share the result of that pipeline instead. If routes, guards or the first rendered view need the flags **before** the app is interactive, load them during bootstrap with `provideAppInitializer()`, which accepts a function returning an Observable or Promise and runs in an injection context. `APP_INITIALIZER` is the older token, deprecated since v19. The service can store the result in a `BehaviorSubject` or a signal, and components read it synchronously from then on. ## Where this sits - The async pipe subscribing twice to an un-shared request is a common way to discover this problem. - Newer code may reach for `httpResource()` or `rxResource()` for request state; those have their own semantics and are a different tool from a hand-built cache. ## Checklist for a shared config request 1. Create the shared Observable **once**, in a field of a root service. 2. Use `shareReplay(1)` so late subscribers get the buffered response. 3. Decide whether failures should be retried (leave errors to reset the cache) or replaced by a cached fallback (`catchError` before `shareReplay`). 4. Decide whether the value must be present **before** first render; if so, load it in `provideAppInitializer()` instead. 5. If the config can change during the session, add an explicit refresh trigger rather than trying to "clear" the cache. An interviewer who asks this question is usually listening for three things: that `HttpClient` is cold, that the cache must be created once, and that you know what happens on error.
- Where should catchError go relative to shareReplay, and why does it matter?If `catchError` sits before `shareReplay`, the fallback value becomes the cached result and no further requests are made. If it sits after, the shared source still errors and resets, so every new subscriber sends a new request and each applies its own fallback. Choose based on whether a failed load should be retried later.
- How would you let the app reload the flags after the user logs in?Replace the fixed request with a trigger: a private `Subject<void>` or `BehaviorSubject` of the user, piped through `switchMap(() => this.http.get(...))` and then `shareReplay(1)`. Emitting on the trigger sends a new request, cancels an in-flight one, and every subscriber receives the fresh flags through the same shared stream.
saying these in an interview costs you the question
- HttpClient caches GET responses by default, so no extra operator is needed
- Returning http.get().pipe(shareReplay(1)) from a method caches across calls
- shareReplay caches an error too, so a failed load needs an app reload
- Calling get() sends the request even if nothing subscribes
- APP_INITIALIZER is the current way to load config before bootstrap