HttpClient is Angular's HTTP service. You call get, post and friends; it returns an Observable that sends the request when you subscribe and emits the parsed response. Underneath it is a small pipeline: your call becomes an immutable HttpRequest, the request passes through any interceptors, and a backend (fetch or XMLHttpRequest) actually talks to the network.
provideHttpClient() is where you configure that pipeline, once, in appConfig (see appConfig and application providers). Each with…() argument is a feature that adds or changes a piece of it. The mental model: HttpClient is the front door everyone uses; provideHttpClient(...) is the building plan that decides which security checks (interceptors) and which delivery service (backend) sit behind it.
bootstrapApplication(App, appConfig) creates the root environment injector from this providers array. provideHttpClient(...) returns EnvironmentProviders that register HttpClient's pipeline there.
withInterceptors([timingInterceptor, authInterceptor]) registers two functional interceptors in that order. Every request runs through timingInterceptor first, then authInterceptor, then the backend (see Interceptors).
No backend feature is listed, so the default applies. In Angular 22.2 the provideHttpClient docs say "By default, HttpClient uses the fetch API to make requests", and withFetch() is marked @deprecated with "withFetch is not required anymore. FetchBackend is the default HttpBackend."
Any component or service can now inject(HttpClient) and get the configured instance — DI from the previous chapter.
AdvancedprovideHttpClient features reference
The features you'll meet, as declared in @angular/common/http 22.2:
Feature
What it does
withInterceptors([...])
Adds functional interceptors (HttpInterceptorFn), in order
withInterceptorsFromDi()
Includes old class-based interceptors registered with the HTTP_INTERCEPTORS multi token; the docs say to prefer withInterceptors "as support for DI-provided interceptors may be phased out"
withXhr()
Use XMLHttpRequest instead of fetch — the docs' reason is upload progress, which fetch can't report
Before signals, every Angular screen that loaded data followed the same recipe: a service method returns an Observable, the component subscribes in ngOnInit, and the component keeps its own loading, error and data fields up to date by hand. It works, it's everywhere in existing code (techStatus included), and you need to be able to write it and explain its weak spots.
// The classic way: subscribe in ngOnInit and track loading/error by hand.@Component({
selector:'app-users-classic',
template:`
@if (loading()) {
<p>Loading…</p>
} @else if (error()) {
<p class="error">{{ error() }}</p>
} @else {
<ul>
@for (user of users(); track user.id) {
<li>{{ user.name }}</li>
}
</ul>
}
`,})exportclassUsersClassicimplementsOnInit{privatereadonly api =inject(UserApi);privatereadonly destroyRef =inject(DestroyRef);protectedreadonly users =signal<User[]>([]);protectedreadonly loading =signal(true);protectedreadonly error =signal<string|null>(null);ngOnInit(){this.api
.getUsers().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({next:(users)=>{this.users.set(users);this.loading.set(false);},error:(err: HttpErrorResponse)=>{this.error.set(`Failed: ${err.status}`);this.loading.set(false);},});}}
What’s happening
The component is created and rendered for the first time with loading = true, so the template shows "Loading…". No request has been sent yet — getUsers() hasn't even been called.
ngOnInit runs and calls api.getUsers(), which returns a cold Observable: a description of a request. Only .subscribe(...) makes HttpClient build the HttpRequest and send GET /api/users (see The observable mental model).
takeUntilDestroyed(this.destroyRef) ties the subscription to the component. If the user navigates away before the response arrives, the subscription is dropped and HttpClientcancels the request (see takeUntilDestroyed).
When my spec flushes [ada, grace], the next callback sets users and flips loading to false; the template re-renders the list. HttpClient then completes the Observable — one request, one response, done.
When the spec flushes a 500 instead, next never runs; the error callback gets an HttpErrorResponse with status 500 and the page shows "Failed: 500". Note how every path has to remember loading.set(false) — forget it in one branch and the spinner never stops.
AdvancedNarrowing a nullable user with as
From my notes: the template uses as so TypeScript doesn't need a null check on every line. With one user that's even more visible:
users-classic.ts
// One user, the classic way — note the `as` alias in the template.@Component({
selector:'app-user-detail-classic',
template:`
@if (user(); as u) {
<h2>{{ u.name }}</h2>
<p>{{ u.email }} · {{ u.role }}</p>
} @else {
<p>Loading…</p>
}
`,})exportclassUserDetailClassicimplementsOnInit{privatereadonly api =inject(UserApi);privatereadonly destroyRef =inject(DestroyRef);readonly id = input.required({ transform: numberAttribute });protectedreadonly user =signal<User |null>(null);ngOnInit(){this.api
.getUser(this.id()).pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user)=>this.user.set(user));}}
What’s happening
user starts as null because nothing has loaded yet, so its type is User | null.
With strict templates, writing {{ user().name }} directly fails the build: "TS2531: Object is possibly 'null'." — the template type checker knows user() can be null.
@if (user(); as u) reads the signal once, checks it's truthy, and binds the narrowed value to u of type User. Inside the block, u.name, u.email and u.role need no ?. (see @if and @else).
ngOnInit reads this.id() once and subscribes. When the response arrives, user.set(user) makes the @if switch from "Loading…" to the details.
http.get<T>(url) returns Observable<T>. The generic tells TypeScript what the parsed JSON body looks like, so users[0].name autocompletes and typos fail to compile. By default the response body is parsed as JSON; responseType: 'text' | 'blob' | 'arraybuffer' changes that (and then the return type changes too).
api.getUsers() returns this.http.get<User[]>('/api/users') — still nothing sent. .subscribe(...) sends it.
In tests, provideHttpClientTesting() replaces the real backend with a fake one, so the request doesn't touch a network; it waits in HttpTestingController (see Testing HTTP calls).
http.expectOne('/api/users') asserts exactly one request to that URL is pending and returns a handle to it; req.request.method is 'GET'.
req.flush([ada]) plays the server: it delivers a 200 with that body. The Observable emits [ada], the callback stores it, and the Observable completes.
result is typed User[] | undefined thanks to the generic, so result[0].email type-checks with no casting.
AdvancedTyped GET generics are unchecked
The generic is a promise you make to the compiler, not a check Angular performs:
user-api.spec.ts
it('the generic is a promise to the compiler, not a runtime check',()=>{let result: User |undefined;
api.getUser(1).subscribe((u)=>(result = u));
http.expectOne('/api/users/1').flush({ id:'1', fullName:'Ada'});console.log('typed as User, actually:', result,typeof result?.id, result?.name);expect(result?.name).toBeUndefined();});// typed as User, actually: { id: '1', fullName: 'Ada' } string undefined
What’s happening
getUser(1) is http.get<User>(...), so result is typed User: id: number, name: string.
The fake server answers with a body that matches none of that: id is a string and the name is under fullName.
HttpClient just JSON.parses the body and hands it over. Nothing compares it to User — TypeScript types don't exist at runtime.
The log shows the truth: typeof result.id is 'string' and result.name is undefined, while the compiler believes both are fine. A template doing {{ user.name.toUpperCase() }} would crash at runtime.
If the API isn't under your control, validate at the boundary: httpResource's parse option, or a schema library (Zod etc.) in a map().
Reads use get; writes send a body. post(url, body), put(url, body) and patch(url, body) take the body as the second argument and serialise plain objects to JSON automatically (setting Content-Type: application/json for you). delete(url) usually has no body.
The difference between the verbs is about meaning, and interviewers like it:
POST — create a new resource (the server picks the id) or trigger an action. Not idempotent: sending it twice creates two things.
PUT — replace the resource at this URL with the body you send. Idempotent: sending the same PUT twice leaves the same state. Fields you leave out are, by the contract, gone.
PATCH — partially update: send only the fields that change.
it('POST sends a JSON body',()=>{let created: User |undefined;
api.create({ name:'Grace', email:'grace@example.com', role:'member'}).subscribe((u)=>(created = u));const req = http.expectOne({ method:'POST', url:'/api/users'});expect(req.request.body).toEqual({ name:'Grace', email:'grace@example.com', role:'member'});console.log('content type detected:', req.request.detectContentTypeHeader());
req.flush({ id:2, name:'Grace', email:'grace@example.com', role:'member'},{ status:201, statusText:'Created'});expect(created?.id).toBe(2);});it('PUT sends the whole resource, PATCH only the change',()=>{
api.replace({...ada, name:'Ada L.'}).subscribe();
api.rename(1,'Ada Lovelace').subscribe();const put = http.expectOne({ method:'PUT', url:'/api/users/1'});const patch = http.expectOne({ method:'PATCH', url:'/api/users/1'});expect(put.request.body).toEqual({ id:1, name:'Ada L.', email:'ada@example.com', role:'admin'});expect(patch.request.body).toEqual({ name:'Ada Lovelace'});
put.flush(put.request.body);
patch.flush({...ada, name:'Ada Lovelace'});});
What’s happening
NewUser is Omit<User, 'id'>, so create() can't be called with an id — the server assigns it. The POST body is exactly the object passed in.
expectOne({ method: 'POST', url: '/api/users' }) matches on both method and URL. req.request.body is the original object (serialisation happens in the backend), and detectContentTypeHeader() reports application/json — what will be sent as Content-Type since we didn't set one.
Flushing with { status: 201, statusText: 'Created' } is still a success: any 2xx goes to next. The created user comes back with id: 2.
The second test fires a PUT and a PATCH to the same URL; matching by method tells them apart. The PUT body is the complete user (id, name, email, role); the PATCH body is only { name: 'Ada Lovelace' }.
Each call needed its own .subscribe(). Without it, nothing is sent — a classic bug in "save" buttons that call this.api.create(form) and forget to subscribe. My spec "is lazy" proves it: before subscribe(), http.expectNone('/api/users') passes.
Two things come up as soon as an API gets real: query strings (?q=ada&page=2) and response metadata (status code, headers like X-Total-Count for pagination).
For query strings, don't concatenate strings — values need URL encoding and arrays need repeating. Pass params: either a plain object, or an HttpParams. HttpParams is immutable: set and append return a new instance, which trips everyone up once.
new HttpParams() is empty. .set('q', term) returns a new HttpParams with q; chaining .set('page', page) returns another with both. Numbers and booleans are accepted and stringified.
In the loop, params = params.append('role', role) reassigns each time. append adds a value without removing existing ones, so role ends up with two values. set would have replaced it.
My spec calls search('ada lovelace', ['admin', 'member'], 2). The request's url is still /api/users, while urlWithParams is /api/users?q=ada%20lovelace&page=2&role=admin&role=member — the space is encoded and the array is repeated. params.getAll('role') is ['admin', 'member'].
searchSimple('ada') uses a plain object instead; HttpClient builds the HttpParams for you: /api/users?q=ada&page=1&active=true. For fixed parameters this is the simplest form.
AdvancedImmutable HttpParams bug
user-api.spec.ts
it('HttpParams is immutable',()=>{const p =newHttpParams();
p.set('q','lost');expect(p.toString()).toBe('');expect(p.set('q','kept').toString()).toBe('q=kept');});
What’s happening
p.set('q', 'lost') creates a new HttpParams and throws it away, because the result isn't stored. p itself never changes.
p.toString() is therefore '' — the classic bug where filters silently don't reach the server.
p.set('q', 'kept') used as an expression gives 'q=kept'. Always reassign (params = params.set(...)) or chain.
AdvancedReading status and headers with observe
To read the status and headers, ask for the whole response with observe: 'response'. The Observable then emits an HttpResponse<T> instead of just the body:
observe: 'response' switches the overload: the return type becomes Observable<HttpResponse<User[]>>. You still get one emission per request, but it's the full response object.
res.body is typed User[] | null — null is possible (for example a 204) — hence ?? [].
res.headers.get('X-Total-Count') reads a response header. Header names are case-insensitive, and get returns string | null, so it's converted with Number(...).
My spec flushes [ada] with { headers: { 'X-Total-Count': '42' } } and gets { items: [ada], total: 42 }. In production, a cross-origin API must also send Access-Control-Expose-Headers: X-Total-Count, or the browser hides the header from JavaScript (see CORS).
The other observe value is 'events', which emits every HttpEvent (sent, upload/download progress, response) — useful for upload progress bars together with reportProgress: true.
httpResource is the signal-based way to fetch data. Instead of "call a method, subscribe, store the result", you declare what the request depends on, and you get back an object of signals — value, status, error, isLoading — that always reflect the latest request. When a signal the request reads changes, it fetches again, cancelling the previous request if it's still running. It's built on HttpClient, so interceptors and HttpTestingController work exactly the same. In @angular/common/http 22.2 its declarations are marked @publicApi 22.0.
The mental model: a spreadsheet cell with a formula =FETCH("/api/users/" & A1). You don't tell it when to refresh; change A1 and the cell recalculates. While it's recalculating it shows "loading", and if the fetch fails it shows the error — the cell always tells you which state it's in.
users-classic.ts
// The same screen with httpResource, driven by a reactive id.@Component({
selector:'app-user-card',
template:`
@if (user.isLoading()) {
<p>Loading…</p>
} @else if (user.error()) {
<p class="error">Could not load user {{ id() }}</p>
} @else if (user.hasValue()) {
<h2>{{ user.value().name }}</h2>
<p>{{ user.value().email }}</p>
}
`,})exportclassUserCard{readonly id = input.required({ transform: numberAttribute });readonly user =httpResource<User>(()=>`/api/users/${this.id()}`);}
What’s happening
httpResource<User>(() => ...) is called in a field initializer — it needs an injection context, like inject() (see The injection context). The argument is a reactive function: it reads this.id(), so the resource tracks the id input signal.
The function returns a URL string, so this is a GET whose JSON body becomes the value. (It can also return a request object with method, body, params, headers — shown below.) With no defaultValue, the value type is User | undefined.
The template reads the state signals. isLoading() is true while a request is in flight, error() holds the failure, and hasValue() both checks for a value and narrows the type so user.value().name compiles without ?..
When the parent changes id, the reactive function re-runs, produces a new URL, and the resource sends a new request — no ngOnChanges, no switchMap, no subscription to clean up.
The resource belongs to the component's injector, so destroying the component destroys the resource and cancels any request in flight.
AdvancedSpec: httpResource loading states
Here's every state, captured from my spec (TestBed.tick() runs pending effects; await fixture.whenStable() lets the response settle):
Before tick, status() already reports 'loading' because the request function has a valid URL — but http.expectOne would fail here: the actual request is sent by an effect, and effects run during change detection. In an app that happens on the first render; in a test, TestBed.tick() does it.
After tick, GET /api/users/1 is pending in HttpTestingController. Status 'loading', isLoadingtrue, no value.
After flush, statusCode() is already 200, but status is still 'loading': the value is delivered asynchronously. After whenStable(), status is 'resolved', isLoading is false, value().name is 'Ada', and the card shows Ada.
id → 2: the reactive function now returns /api/users/2. Status goes back to 'loading' and — this is important — value is undefined again: a new request clears the old value. (Compare reload() below, which keeps it.)
Flushing Grace resolves it. The component never subscribed to anything; it just described the request in terms of id.
AdvancedSpec: error and reload states
The other states, each proven in the same spec file:
users-classic.spec.ts
it('error state: error() is set and value() throws',async()=>{const fixture = TestBed.createComponent(UserCard);
fixture.componentRef.setInput('id',99);
TestBed.tick();
http.expectOne('/api/users/99').flush({ message:'nope'},{ status:404, statusText:'Not Found'});await fixture.whenStable();const user = fixture.componentInstance.user;console.log('error status:', user.status(),'| error:', user.error()?.message,'| statusCode:', user.statusCode());let thrown ='';try{
user.value();}catch(e){
thrown =(e as Error).message;}console.log('value() threw:', thrown);expect(user.status()).toBe('error');expect(fixture.nativeElement.textContent).toContain('Could not load user 99');});// error status: error | error: Http failure response for /api/users/99: 404 Not Found | statusCode: 404// value() threw: Resource is currently in an error state (see Error.cause for details): Http failure response for /api/users/99: 404 Not Found
What’s happening
A 404 makes HttpClient error, and the resource moves to status()'error'. error() is the HttpErrorResponse ("Http failure response for /api/users/99: 404 Not Found") and statusCode() is 404.
value() throws in the error state: "Resource is currently in an error state (see Error.cause for details): …". The Resource docs say the same — "The current value of the Resource, or throws an error if the resource is in an error state."
That's why UserCard's template checks error()before touching value(), and why hasValue() exists: it returns false in the error state instead of throwing.
The card shows "Could not load user 99". No catchError, no error callback — the error is just another state to render.
AdvancedhttpResource status reference table
status()
When
value()
isLoading()
'idle'
The request function returned undefined
defaultValue (or undefined)
false
'loading'
A new request is in flight (first load or the inputs changed)
defaultValue (or undefined)
true
'reloading'
reload() re-sent the same request
the previous value
true
'resolved'
The response arrived
the parsed body
false
'error'
The request or parse failed
throws
false
'local'
You called set() / update()
what you set
false
Every row except 'idle' with no default was asserted in my spec: reload() gave reloading true Ada (old value kept while the request runs, then 'resolved' with 'Ada v2'), and user.set({...}) gave 'local' — handy for optimistic updates.
Returning undefined from the request function means "don't fetch yet" — the resource goes idle. Combined with the request-object form and defaultValue, that makes a search box:
users-classic.ts
// Returning undefined from the request function means "don't fetch yet".@Component({
selector:'app-user-search',
template:`
<p>{{ results.status() }}: {{ results.value().length }} results</p>
`,})exportclassUserSearch{readonly term =signal('');readonly results =httpResource<User[]>(()=>(this.term().length <2?undefined:{ url:'/api/users', params:{ q:this.term()}}),{ defaultValue:[]},);}
What’s happening
While term has fewer than 2 characters, the function returns undefined. My spec sees status()'idle', value()[], and http.expectNone(() => true) — no request of any kind.
defaultValue: [] changes the value type from User[] | undefined to User[], so the template can call .length with no check. The default is shown while idle or loading.
Setting term to 'ad' makes the function return a request object. params: { q: 'ad' } is serialised for you: the spec matches /api/users?q=ad.
After the flush settles, the template reads "resolved: 1 results". (A real search box would also debounce the term — see debounceTime and distinctUntilChanged.)
AdvancedValidating responses with parse
Remember the http.get<User> lie from Typed GET requests? parse runs on the raw body and either returns a typed value or throws, which puts the resource in the error state:
users-classic.ts
// parse: validate at runtime what the generic only promises at compile time.functiontoUser(raw:unknown): User {const u = raw as Partial<User>;if(typeof u?.id !=='number'||typeof u.name !=='string'){thrownewError('Response is not a User');}return u as User;}@Component({ selector:'app-checked-user', template:``})exportclassCheckedUser{readonly id =signal(1);readonly user =httpResource(()=>`/api/users/${this.id()}`,{ parse: toUser });}
What’s happening
There's no <User> generic here: the result type is inferred from parse's return type, so the type and the runtime check can't drift apart.
parse receives the JSON as unknown — the honest type for data from the network — and must prove it's a User or throw.
My spec flushes { id: '1', fullName: 'Ada' }. toUser throws, and the resource ends up with status()'error' and error().message'Response is not a User'.
The docs suggest exactly this use: "useful for validating backend responses using a runtime schema validation library such as Zod."
AdvancedPitfall: httpResource for writes
?Which status, which value?
UserCard has loaded user 1 (status'resolved', value Ada). Then, separately: (a) user.reload() is called; (b) the id input changes to 2. Before either response arrives, what are status() and value()?.name in each case?
Show answer
(a) 'reloading' and 'Ada'. (b) 'loading' and undefined.
What’s happening
reload() re-sends the same request. The resource treats it as a refresh: status 'reloading', and the old value stays visible so the UI doesn't flash empty. My spec logged reloading true Ada.
Changing id produces a different request. The old value belongs to a different user, so showing it would be wrong: status 'loading' and value undefined (or the defaultValue). My spec logged { status: 'loading', isLoading: true, value: undefined }.
In both cases isLoading() is true — it's the one flag that covers both. Use status() when you need to tell them apart, e.g. a small spinner for reloading versus a skeleton for loading.
Both load data. The difference is who decides when to fetch.
With ngOnInit + subscribe, you decide: you fetch once, at a lifecycle moment, with whatever inputs exist then. Every later change — a new route param, a filter, a retry button — is extra code you write: ngOnChanges, a Subject with switchMap, manual cancellation, manual loading/error flags.
With httpResource, the dependencies decide: the request is a function of signals, so any change to them re-fetches, and the state signals come for free.
ngOnInit + subscribe
httpResource
Fetches when
Once, in ngOnInit
Whenever the signals it reads change
Input changes
Ignored unless you add ngOnChanges/switchMap
Re-fetches automatically
Stale request
You cancel it (switchMap, takeUntilDestroyed)
Cancelled automatically — my spec sees first.cancelled === true
it('cancels the in-flight request when the id changes',()=>{const fixture = TestBed.createComponent(UserCard);
fixture.componentRef.setInput('id',1);
TestBed.tick();const first = http.expectOne('/api/users/1');
fixture.componentRef.setInput('id',2);
TestBed.tick();console.log('first cancelled?', first.cancelled);expect(first.cancelled).toBe(true);
http.expectOne('/api/users/2').flush(grace);});// first cancelled? true
What’s happening
id is 1 and the tick sends GET /api/users/1. The spec deliberately doesn't answer it — the user is on a slow network.
id changes to 2 before the response. The next tick re-runs the request function and starts GET /api/users/2.
The resource unsubscribes from the first request, and HttpTestingController records that: first.cancelled is true. In a browser, the fetch would be aborted.
This prevents the classic race where the response for user 1 arrives after user 2's and overwrites it. With subscribe in ngOnInit you'd need switchMap to get the same guarantee (see switchMap).
The matching ngOnInit spec in HttpClient the classic way shows the opposite: change the id and no request happens at all.
When a request fails, HttpClient doesn't emit a value — it errors the Observable with an HttpErrorResponse. There are two kinds of failure, and they look different:
The server answered with an error status (4xx/5xx): status is that code, error is the parsed response body, ok is false.
No response at all — offline, DNS failure, CORS rejection, request aborted: status is 0 and statusText is "Unknown Error".
In RxJS style you handle errors with operators in pipe(): retry re-subscribes (which re-sends the request), catchError replaces the error with a fallback value or a better error (see Error handling with catchError and finalize). Order matters: retry first, then catch what's left.
user-api.spec.ts
it('an error status arrives as HttpErrorResponse on the error channel',()=>{let error: HttpErrorResponse |undefined;
api.getUser(99).subscribe({error:(e)=>(error = e)});
http.expectOne('/api/users/99').flush({ message:'no such user'},{ status:404, statusText:'Not Found'});console.log('404 message:', error?.message,'| ok:', error?.ok,'| body:', error?.error);expect(error).toBeInstanceOf(HttpErrorResponse);expect(error?.status).toBe(404);expect(error?.error).toEqual({ message:'no such user'});});// 404 message: Http failure response for /api/users/99: 404 Not Found | ok: false | body: { message: 'no such user' }it('a network failure is status 0',()=>{let error: HttpErrorResponse |undefined;
api.getUser(1).subscribe({error:(e)=>(error = e)});
http.expectOne('/api/users/1').error(newProgressEvent('error'));console.log('network message:', error?.message,'| status:', error?.status,'| statusText:', error?.statusText);expect(error?.status).toBe(0);});// network message: Http failure response for /api/users/1: 0 | status: 0 | statusText: Unknown Error
What’s happening
Flushing a body with status: 404 makes HttpClient treat it as a failure: the next callback never runs, the error callback gets an HttpErrorResponse.
Its message is generated ("Http failure response for /api/users/99: 404 Not Found"); the server's JSON body is on error.error — { message: 'no such user' }. Show users the latter, log the former.
req.error(new ProgressEvent('error')) simulates a request that never got a response. status is 0 and statusText is "Unknown Error" — you can't tell offline from CORS from DNS, only "no response".
After an error the Observable is finished. It won't emit again unless something re-subscribes — which is exactly what retry does.
AdvancedRetrying only transient failures
Retrying blindly is a mistake: a 404 or 400 will fail the same way every time, and hammering a struggling server makes it worse. Retry only failures that might be transient (status 0 and 5xx), with a delay that grows:
user-api.ts
getUserWithRetry(id:number): Observable<User>{returnthis.http.get<User>(`${this.base}/${id}`).pipe(retry({
count:2,delay:(error: HttpErrorResponse, attempt)=>{// only retry what might succeed next time: network errors (0) and 5xxif(error.status !==0&& error.status <500)returnthrowError(()=> error);returntimer(attempt *1000);// 1s, then 2s},}),);}getUserOrNull(id:number): Observable<User |null>{returnthis.getUserWithRetry(id).pipe(catchError((error: HttpErrorResponse)=>{if(error.status ===404)returnof(null);returnthrowError(()=>newError(`Could not load user ${id} (status ${error.status})`));}),);}
AdvancedSpec: retry with backoff and fake timers
user-api.spec.ts
it('retries a 503 twice with growing delays, then succeeds',()=>{let result: User |null|undefined;
api.getUserOrNull(1).subscribe((u)=>(result = u));
http.expectOne('/api/users/1').flush(null,{ status:503, statusText:'Service Unavailable'});// attempt 1
vi.advanceTimersByTime(999);
http.expectNone('/api/users/1');// still waiting
vi.advanceTimersByTime(1);
http.expectOne('/api/users/1').flush(null,{ status:503, statusText:'Service Unavailable'});// attempt 2
vi.advanceTimersByTime(2000);
http.expectOne('/api/users/1').flush(ada);// attempt 3expect(result).toEqual(ada);});
What’s happening
The spec uses vi.useFakeTimers() (in beforeEach), so timer() only fires when the test advances the clock.
Attempt 1 answers 503. The error reaches retry, which calls delay(error, 1): status 503 is ≥ 500, so it returns timer(1000). retry waits for that timer before re-subscribing.
At 999 ms expectNone passes — no second request yet. At 1000 ms the timer fires, retry re-subscribes to http.get(...), and attempt 2 is sent. It answers 503 too; delay(error, 2) returns timer(2000).
After 2000 ms more, attempt 3 is sent and answers with Ada. The value passes through retry and catchError untouched, and result is Ada. Three requests for one subscribe.
count: 2 means two retries, so three attempts in total. The next spec flushes 500 three times and gets 'Could not load user 1 (status 500)' from catchError, after exactly three requests.
For a 404, delay returns throwError(...), which tells retry to stop immediately. catchError turns that into of(null) — "user doesn't exist" becomes a normal value. My spec advances 10 seconds and expectNone confirms no retry was sent.
An interceptor is a function that sits between HttpClient and the backend and sees every request on the way out and every response event on the way back. It's the place for cross-cutting concerns you don't want in every service: auth headers, logging and timing, a global error toast, a base URL, caching, a loading counter. If you know Express, it's middleware for the client (see Middleware and the request pipeline on the Node site for the server side of the same idea).
In Angular 22 an interceptor is an HttpInterceptorFn: (req, next) => Observable<HttpEvent>. It can change the request (always by cloning — HttpRequest is immutable), call next(req) to pass it down the chain, and pipe operators onto what comes back. The mental model is a stack of envelopes: on the way out, each interceptor can put the letter in a new envelope with an extra stamp; on the way back, the reply is unwrapped in the reverse order.
it('requests go first → second; responses come back second → first',()=>{
callLog.length =0;
TestBed.configureTestingModule({
providers:[provideHttpClient(withInterceptors([firstInterceptor, secondInterceptor])),provideHttpClientTesting()],});const http = TestBed.inject(HttpClient);const ctrl = TestBed.inject(HttpTestingController);
http.get('/api/ping').subscribe(()=> callLog.push('subscriber: value'));const req = ctrl.expectOne('/api/ping');expect(req.request.headers.get('X-Second')).toBe('yes');
callLog.push('backend: flush');
req.flush({ ok:true});expect(callLog).toEqual(['first: request','second: request','second: Sent','first: Sent','backend: flush','second: Response','first: Response','subscriber: value',]);});
What’s happening
subscribe() starts the chain. firstInterceptor runs first because it's first in withInterceptors([...]): it logs first: request and calls next(req), which invokes secondInterceptor.
secondInterceptor logs second: request, then req.clone({ setHeaders: { 'X-Second': 'yes' } }) makes a new request with an extra header and passes that to next — here, the backend. The test sees X-Second: yes on the request that reached the backend.
Surprise number one: before any response, the log shows second: Sent then first: Sent. Interceptors don't just see the response — they see the whole event stream, and the first event is HttpEventType.Sent, emitted as soon as the request is dispatched. That's why interceptors that only care about responses filter with event instanceof HttpResponse or event.type === HttpEventType.Response.
req.flush(...) delivers the response event. It travels back up the chain in reverse order: second: Response, then first: Response, then HttpClient extracts the body and the subscriber logs subscriber: value.
So "first" in the array means outermost: it sees the request first and the response last. Put things that must see the final outgoing request (logging, timing) first, and things that should run closest to the network (retries of the raw request) last.
AdvancedTiming interceptor with finalize
A practical one — timing every request — shows the shape most interceptors share:
interceptors.ts
exportconst timingInterceptor:HttpInterceptorFn=(req, next)=>{const started = performance.now();returnnext(req).pipe(finalize(()=>console.log(`${req.method}${req.urlWithParams} took ${Math.round(performance.now()- started)}ms`)),);};
What’s happening
The function body runs when the request is made — each subscription runs the chain again — so started is per request.
next(req) passes the request on unchanged and returns the Observable of events.
finalize runs when that Observable completes, errors or is unsubscribed (a cancelled request), so the timing is logged in every case. tap would miss errors and cancellations unless you handled all three.
Returning the piped Observable is mandatory: an interceptor that forgets return breaks every request.
The most common interceptor in real apps: attach the user's access token to every API call, and when the API answers 401 Unauthorized (the token expired), get a new token with the refresh token and replay the original request — invisibly to the component. If the refresh itself fails, the session is over: log out and send the user to /login. The token side of this (short-lived access tokens, refresh-token rotation, where to store them) is covered on the Node site: Access tokens and refresh tokens and Where to store tokens in the browser.
This is the "important example of flow" from my notes, as code:
interceptors.ts
exportconstSKIP_AUTH=newHttpContextToken<boolean>(()=>false);@Service()exportclassAuthService{privatereadonly http =inject(HttpClient);privatereadonly router =inject(Router);readonly accessToken =signal<string|null>(null);private refreshInFlight: Observable<string>|null=null;login(token:string){this.accessToken.set(token);}// One refresh at a time: concurrent 401s share the same request.refresh(): Observable<string>{this.refreshInFlight ??=this.http
.post<{ accessToken:string}>('/api/auth/refresh',null,{
withCredentials:true,// the refresh token lives in an HttpOnly cookie
context:newHttpContext().set(SKIP_AUTH,true),}).pipe(map((res)=> res.accessToken),tap((token)=>this.accessToken.set(token)),finalize(()=>(this.refreshInFlight =null)),shareReplay(1),);returnthis.refreshInFlight;}logout(){this.accessToken.set(null);this.router.navigateByUrl('/login');}}constwithToken=(req: HttpRequest<unknown>, token:string)=>
req.clone({ setHeaders:{ Authorization:`Bearer ${token}`}});exportconst authInterceptor:HttpInterceptorFn=(req: HttpRequest<unknown>, next: HttpHandlerFn)=>{if(req.context.get(SKIP_AUTH)||!req.url.startsWith('/api/')){returnnext(req);// public endpoints, the refresh call itself, third-party URLs}const auth =inject(AuthService);// ✅ interceptors run in an injection contextconst token = auth.accessToken();const authed = token ?withToken(req, token): req;returnnext(authed).pipe(catchError((error:unknown)=>{if(!(error instanceofHttpErrorResponse)|| error.status !==401){returnthrowError(()=> error);}// Access token expired: refresh once, then replay the original request.return auth.refresh().pipe(catchError((refreshError)=>{
auth.logout();// the refresh failed too: the session is overreturnthrowError(()=> refreshError);}),switchMap((fresh)=>next(withToken(req, fresh))),);}),);};
What’s happening
Skip list first.SKIP_AUTH is an HttpContextToken — per-request metadata that travels with the request but is never sent to the server. The refresh call sets it, so the interceptor doesn't attach an expired token to the refresh or, worse, try to refresh the refresh. Requests that don't start with /api/ (a CDN, a third-party API) also pass straight through, so your token never leaks to another origin.
Attach the token.inject(AuthService) runs at the top of the interceptor, inside its injection context. withToken clones the request with Authorization: Bearer <token>; the original req is untouched (and kept for the replay).
Happy path.next(authed) sends it; if it succeeds, catchError never runs and the response flows back to the caller unchanged.
401 → refresh. Any other error (403, 500, network) is rethrown untouched — a 403 means "you're not allowed", and refreshing won't change that. On a 401, auth.refresh() POSTs to /api/auth/refresh with withCredentials: true, so the browser sends the HttpOnly refresh-token cookie. The new access token is stored by the tap.
Replay.switchMap((fresh) => next(withToken(req, fresh))) re-sends the original request with the new token, through the rest of the chain. The component that called http.get('/api/orders') just receives the orders — it never saw the 401.
Refresh failed → log out. The inner catchError sits beforeswitchMap, so it only catches a failed refresh, not an error from the replayed request. It clears the token, navigates to /login, and rethrows so the caller's error callback still runs.
One refresh at a time. If three requests 401 at once, they must not trigger three refreshes — with rotating refresh tokens, the second refresh would use an already-spent token and look like token theft. refreshInFlight ??= ... with shareReplay(1) makes every caller share one in-flight refresh; finalize clears it when it completes so the next expiry starts fresh.
AdvancedSpec: refresh-and-replay branches
Each branch is a test:
interceptors.spec.ts
it('on 401: refreshes once, replays the request with the new token',()=>{
auth.login('expired');let result:unknown;
http.get('/api/orders').subscribe((r)=>(result = r));
ctrl.expectOne('/api/orders').flush(null,{ status:401, statusText:'Unauthorized'});const refresh = ctrl.expectOne('/api/auth/refresh');expect(refresh.request.method).toBe('POST');expect(refresh.request.withCredentials).toBe(true);expect(refresh.request.headers.has('Authorization')).toBe(false);
refresh.flush({ accessToken:'fresh'});const replay = ctrl.expectOne('/api/orders');expect(replay.request.headers.get('Authorization')).toBe('Bearer fresh');
replay.flush([{ id:1}]);expect(result).toEqual([{ id:1}]);expect(auth.accessToken()).toBe('fresh');});it('refresh fails: logs out and redirects to /login',()=>{
auth.login('expired');let error: HttpErrorResponse |undefined;
http.get('/api/orders').subscribe({error:(e)=>(error = e)});
ctrl.expectOne('/api/orders').flush(null,{ status:401, statusText:'Unauthorized'});
ctrl.expectOne('/api/auth/refresh').flush(null,{ status:401, statusText:'Unauthorized'});expect(error?.status).toBe(401);expect(auth.accessToken()).toBeNull();expect(navigate).toHaveBeenCalledWith('/login');});
What’s happening
The beforeEach (not shown) provides provideRouter([]), provideHttpClient(withInterceptors([authInterceptor])) and provideHttpClientTesting(), and spies on Router.navigateByUrl.
Refresh-and-replay test: the first /api/orders goes out with Bearer expired and is answered 401. The interceptor immediately sends POST /api/auth/refresh — with withCredentialstrue and noAuthorization header, thanks to SKIP_AUTH.
Flushing { accessToken: 'fresh' } makes switchMap send /api/orders a second time, now with Bearer fresh. Flushing the orders delivers them to the original subscriber; result is [{ id: 1 }] and the service holds 'fresh'.
Refresh-fails test: the refresh is answered 401 as well. The inner catchError calls logout() — token null, navigateByUrl('/login') — and rethrows, so the subscriber's error callback receives the 401.
Two more specs cover the rest: two concurrent 401s produce ctrl.match('/api/auth/refresh').length === 1 (one shared refresh, both requests replayed), and a 403 is passed through with ctrl.expectNone('/api/auth/refresh').
toSignal(observable$) converts an Observable into a signal: it subscribes immediately, stores the latest emission in a signal, and unsubscribes automatically when the surrounding component or service is destroyed (see The async pipe and toSignal()). That automatic cleanup is the reason for the rule my notes flag: toSignal needs to know whose lifetime it's tied to, so it must run in an injection context — a field initializer, a constructor, a factory — or be given an injector explicitly.
to-signal.ts
@Component({
selector:'app-user-list-signal',
template:`
@for (user of users(); track user.id) {
<li>{{ user.name }}</li>
} @empty {
<li>No users yet</li>
}
`,})exportclassUserListSignal{privatereadonly api =inject(UserApi);// ✅ field initializer = injection context. Subscribes now, unsubscribes on destroy.readonly users =toSignal(this.api.getUsers(),{ initialValue:[]as User[]});}@Component({ selector:'app-late-signal', template:``})exportclassLateSignalimplementsOnInit{privatereadonly api =inject(UserApi);privatereadonly injector =inject(Injector);
users?: Signal<User[]|undefined>;ngOnInit(){this.users =toSignal(this.api.getUsers());// ❌ NG0203 — ngOnInit is not an injection context}loadLater(){this.users =toSignal(this.api.getUsers(),{ injector:this.injector });// ✅ explicit injector}}
What’s happening
In UserListSignal, toSignal runs during construction. It grabs the component's DestroyRef from the current injector and subscribes to getUsers() right away — my spec sees the GET /api/users immediately after createComponent, before any change detection.
initialValue: [] is what the signal holds until the response arrives, so the template shows "No users yet". Without it the type would be User[] | undefined.
When the spec flushes Ada, the signal updates and the list re-renders. When the spec destroys the component before responding, req.cancelled is true: the DestroyRef callback unsubscribed, which cancelled the HTTP request.
LateSignal.ngOnInit calls toSignal after construction. There's no current injector, so it throws: "NG0203: toSignal() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with runInInjectionContext."
loadLater() passes { injector: this.injector } (captured during construction). toSignal takes the DestroyRef from that injector instead, so it works anywhere — my spec flushes [] and reads it back.
AdvancedPitfall: expecting toSignal to refetch
?Which line throws?
TypeScript
exportclassProfile{private api =inject(UserApi);private injector =inject(Injector);
a =toSignal(this.api.getUsers());// 1
b?: Signal<User[]|undefined>;constructor(){this.b =toSignal(this.api.getUsers());}// 2ngOnInit(){const c =toSignal(this.api.getUsers());}// 3onClick(){runInInjectionContext(this.injector,()=>toSignal(this.api.getUsers()));}// 4}
Show answer
Only line 3.
What’s happening
Line 1 is a field initializer and line 2 is the constructor body — both run while DI is creating Profile, so toSignal finds the injector.
Line 3 runs in ngOnInit, which Angular calls after construction from change detection — no injection context, so NG0203.
Line 4 runs later too, but runInInjectionContext re-establishes the context with the captured injector for the duration of the callback, so it works (see The injection context).
My spec builds this class, calls onClick(), and finds three pending /api/users requests — from lines 1, 2 and 4. Line 3's error message is the NG0203 text quoted above.
Every example in this chapter was verified the same way: provideHttpClient() + provideHttpClientTesting() in TestBed, and HttpTestingController to inspect and answer requests. provideHttpClientTesting() swaps the real backend for a fake one; requests go through your real HttpClient and real interceptors, then stop and wait in the controller until the test answers them. Nothing touches the network, and the test decides the timing — which is how the retry and race-condition specs above could control every step.
The mental model: the controller is a switchboard operator. Your code places calls; they light up on the board. The test picks up a specific line (expectOne), checks who's calling and what they said (req.request), and answers (flush) — or hangs up with an error (flush with an error status, or error(new ProgressEvent('error'))). At the end, verify() checks no lines are still ringing.
user-api.spec.ts
describe('UserApi',()=>{let api: UserApi;let http: HttpTestingController;beforeEach(()=>{
TestBed.configureTestingModule({
providers:[provideHttpClient(),provideHttpClientTesting()],});
api = TestBed.inject(UserApi);
http = TestBed.inject(HttpTestingController);});afterEach(()=> http.verify());it('is lazy: nothing is sent until you subscribe',()=>{const users$ = api.getUsers();
http.expectNone('/api/users');
users$.subscribe();
http.expectOne('/api/users').flush([]);});});
What’s happening
provideHttpClient() configures the real client (add withInterceptors([...]) to test with interceptors), and provideHttpClientTesting() — listed after it — replaces its backend with HttpClientTestingBackend.
TestBed.inject(UserApi) gives the real service, injecting the real HttpClient. Nothing in the service changes for tests: that's dependency inversion done by the framework.
api.getUsers() only builds an Observable: expectNone('/api/users') passes. subscribe() sends the request into the fake backend, and expectOne finds it. flush([]) answers it.
afterEach(() => http.verify()) fails the test if any request is still unanswered — it catches requests your code made that the test didn't expect.
AdvancedHttpTestingController API reference
The API in one place (from @angular/common/http/testing 22.2):
Call
Does
expectOne(url | { method, url } | predicate)
Asserts exactly one matching request is pending; returns a TestRequest
expectNone(...)
Asserts no matching request was made
match(...)
Returns all matching requests (possibly none), without asserting
verify()
Fails if any request is unanswered (cancelled ones must be matched too unless { ignoreCancelled: true })
req.request
The HttpRequest that reached the backend: method, url, urlWithParams, body, headers, withCredentials, context
req.flush(body, { status, statusText, headers })
Answers; a non-2xx status makes it an HttpErrorResponse
req.error(new ProgressEvent('error'))
Simulates no response (status 0)
req.cancelled
true if the caller unsubscribed before the answer
Matching on a string compares against the full URL including the query string — expectOne('/api/users?page=3') matched the getPage(3) request. To match regardless of params, use a predicate: expectOne((r) => r.url === '/api/users').
The failure messages are worth recognising:
to-signal.spec.ts
it('expectOne failure message',()=>{
TestBed.configureTestingModule({ providers:[provideHttpClient(),provideHttpClientTesting()]});const http = TestBed.inject(HttpTestingController);
TestBed.inject(UserApi).getUser(1).subscribe();let message ='';try{
http.expectOne('/api/user/1');}catch(e){
message =(e as Error).message;}let verifyMessage ='';try{
http.verify();}catch(e){
verifyMessage =(e as Error).message;}
http.expectOne('/api/users/1').flush({});
http.verify();});// expectOne: Expected one matching request for criteria "Match URL: /api/user/1", found none. Requests received are: GET /api/users/1.// verify: Expected no open requests, found 1: GET /api/users/1
What’s happening
The service requests /api/users/1, but the test (deliberately) expects /api/user/1 — singular. expectOne fails with "Expected one matching request for criteria "Match URL: /api/user/1", found none. Requests received are: GET /api/users/1." — it lists what was sent, which usually shows the typo immediately.
verify() at that point fails with "Expected no open requests, found 1: GET /api/users/1", because the real request is still unanswered.
Matching the right URL and flushing it clears the board, and the final verify() passes.
AdvancedPitfall: testing providers in the wrong order
AdvancedFlushing effects in HTTP tests
AdvancedFurther reading: component and service HTTP tests