Almost every Angular app talks to a server. HttpClient — Angular’s HTTP layer — has been the standard since v4 and is the foundation that every higher-level API in the framework (resolvers, resource, httpResource) builds on. This lesson is the basics: setup, GET, POST, params, headers, response types, and the observe option that unlocks progress events and full response access.
This is lesson 6.1 of the Angular Tutorial, opening Module 6. After this lesson you can make any REST API call. Lessons 6.2–6.6 build interceptors, httpResource, error handling, caching, and SSE on top.
Setup
Register HttpClient as a provider in app.config.ts (lesson 1.6):
import { provideHttpClient, withFetch } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withFetch(), // use the Fetch API instead of XHR
),
],
};
The withFetch() feature is the modern default — uses the browser’s fetch API, integrates with Service Workers, supports streaming responses. The default without withFetch() falls back to XHR (older API).
The simplest GET
Inject HttpClient in any service or component and call .get():
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-users',
template: `
@if (users()) {
<ul>@for (u of users()!; track u.id) { <li>{{ u.name }}</li> }</ul>
} @else {
<p>Loading...</p>
}
`,
})
export class UsersComponent {
private http = inject(HttpClient);
users = toSignal(this.http.get<User[]>('/api/users'));
}
Three facts:
.get<T>()— type the response. The Observable emitsT.- The Observable emits ONCE with the response body, then completes. (Unlike RxJS Subjects, which are open-ended.)
toSignal()bridges to signal-shaped consumption. Lesson 3.7 covered this pattern.
HTTP verbs
Every REST verb has a method on HttpClient:
this.http.get<User[]>('/api/users');
this.http.post<User>('/api/users', { name: 'Pradeep' });
this.http.put<User>('/api/users/42', { name: 'Pradeep B' });
this.http.patch<User>('/api/users/42', { name: 'New name' });
this.http.delete<void>('/api/users/42');
this.http.head<void>('/api/users/42'); // get only headers
this.http.options<unknown>('/api/users'); // OPTIONS preflight
Each returns an Observable that emits the typed body and completes. Failed requests (status 400+) emit an error to the subscription’s error handler instead.
Query parameters
For URL query strings, use the params option:
import { HttpParams } from '@angular/common/http';
this.http.get('/api/search', {
params: { q: 'angular', sort: 'date', limit: 20 },
});
// → GET /api/search?q=angular&sort=date&limit=20
The object form works for primitives. For complex cases:
let params = new HttpParams()
.set('q', 'angular')
.append('tag', 'tutorial')
.append('tag', 'angular22'); // appends, not replaces — for multi-value
this.http.get('/api/search', { params });
The set vs append distinction matters for keys that appear multiple times (?tag=a&tag=b).
Headers
For custom headers (auth tokens, content-type overrides):
this.http.get('/api/secure', {
headers: { 'Authorization': 'Bearer ' + token },
});
// Or via HttpHeaders
import { HttpHeaders } from '@angular/common/http';
const headers = new HttpHeaders({
'Authorization': 'Bearer ' + token,
'X-Trace-ID': 'abc-123',
});
this.http.get('/api/secure', { headers });
For headers added to EVERY request (auth tokens for every API call), use a functional interceptor (lesson 6.2). Per-request headers are for one-offs.
Request body
For post, put, patch, the second argument is the body:
this.http.post<User>('/api/users', { name: 'Pradeep', email: 'p@b.c' });
HttpClient auto-detects the body type:
- Plain object →
application/json(stringified automatically) FormData→multipart/form-datawith boundary (don’t set Content-Type manually)URLSearchParams→application/x-www-form-urlencodedBlob/ArrayBuffer→ raw binarystring→ text/plain (unless you override)
The most-common bug: setting Content-Type: application/json on a FormData upload. That breaks the multipart boundary. Let HttpClient choose.
Response type
By default, HttpClient parses responses as JSON. For other types:
this.http.get('/api/download.csv', { responseType: 'text' }); // string
this.http.get('/api/file.pdf', { responseType: 'blob' }); // Blob
this.http.get('/api/data.bin', { responseType: 'arraybuffer' }); // ArrayBuffer
this.http.get('/api/data.json', { responseType: 'json' }); // default — typed
The responseType changes what the Observable emits. For typed JSON, use the generic: .get<User>(...) — the response is parsed AND typed.
observe option — full response access
By default, the Observable emits only the response BODY. Use observe: 'response' to get the full HttpResponse:
this.http.get<User[]>('/api/users', { observe: 'response' }).subscribe(res => {
console.log(res.status); // 200
console.log(res.headers.get('X-Total-Count')); // pagination metadata
console.log(res.body); // typed body
});
For progress events (upload progress, lesson 4.5):
this.http.post('/api/upload', form, {
reportProgress: true,
observe: 'events', // emit ALL lifecycle events
}).subscribe(event => { /* check event.type */ });
Three observe modes:
'body'(default) — emit just the body'response'— emit the fullHttpResponse(headers, status, body)'events'— emit every event (Sent, ResponseHeader, UploadProgress, Response)
For most cases, default 'body'. Use 'response' when you need pagination headers or status codes. Use 'events' for upload progress.
Auto-unsubscribe with toSignal
The pattern from earlier:
users = toSignal(this.http.get<User[]>('/api/users'));
// ^? Signal<User[] | undefined>
The Observable completes after one emission; toSignal correctly handles completion. No subscription leaks; no manual cleanup.
For an initial value (so the signal isn’t undefined while loading):
users = toSignal(this.http.get<User[]>('/api/users'), { initialValue: [] });
// ^? Signal<User[]>
Service pattern
Real apps put HTTP behind a service:
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
list() {
return this.http.get<User[]>('/api/users');
}
load(id: string) {
return this.http.get<User>(`/api/users/${id}`);
}
async loadAsync(id: string): Promise<User> {
return firstValueFrom(this.http.get<User>(`/api/users/${id}`));
}
create(payload: Omit<User, 'id'>) {
return this.http.post<User>('/api/users', payload);
}
update(id: string, payload: Partial<User>) {
return this.http.patch<User>(`/api/users/${id}`, payload);
}
remove(id: string) {
return this.http.delete<void>(`/api/users/${id}`);
}
}
Components inject the service, not HttpClient directly. The service centralizes URL construction, response typing, and (eventually) caching.
firstValueFrom for async/await ergonomics
When a method is async-await (resolvers, route guards, app initializers), firstValueFrom converts an Observable into a Promise:
import { firstValueFrom } from 'rxjs';
async resolve() {
const user = await firstValueFrom(this.http.get<User>('/api/me'));
return user;
}
The Promise resolves with the first emission and rejects on error. Use anywhere async-await is cleaner than .subscribe().
Common gotchas
| Symptom | Cause | Fix |
|---|---|---|
NullInjectorError: No provider for HttpClient! |
Forgot provideHttpClient() in appConfig |
Add it |
Response body is string "..." instead of object |
Server sent JSON but Content-Type is text/* |
Server fix, OR use responseType: 'json' to force parsing |
| FormData upload fails with 400 | Manually set Content-Type: multipart/form-data |
Don’t — HttpClient sets the right boundary automatically |
| Subscription leaks | .subscribe() in component code without cleanup |
Use toSignal(), OR pipe through takeUntilDestroyed() |
| HttpClient timing out on slow network | No client-side timeout configured | Pipe through timeout(N_ms) from RxJS |
| Multiple GET requests for same data | Each .subscribe() triggers a fresh request |
Share via shareReplay(1), OR (better) use httpResource() (lesson 6.3) |
What’s next
Lesson 6.2 covers functional interceptors — auth headers, retries, logging applied to every request. Lesson 6.3 dives into httpResource() — the signal-shaped HTTP API. Lesson 6.4 covers error handling and retry patterns. Lesson 6.5 covers caching. Lesson 6.6 walks server-sent events and streaming HTTP.
Try it yourself
A complete GET-list + click-detail flow:
import { Component, inject, signal } from '@angular/core';
import { HttpClient, provideHttpClient, withFetch } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { bootstrapApplication } from '@angular/platform-browser';
@Component({
selector: 'app-users',
template: `
@if (users()) {
<ul>@for (u of users()!; track u.id) { <li (click)="load(u.id)">{{ u.name }}</li> }</ul>
} @else { <p>Loading...</p> }
@if (current()) { <h2>{{ current()!.name }}</h2> }
`,
})
export class UsersComponent {
private http = inject(HttpClient);
users = toSignal(this.http.get<{ id: number; name: string }[]>('https://jsonplaceholder.typicode.com/users'));
current = signal<{ id: number; name: string } | null>(null);
load(id: number) {
this.http.get<typeof this.current.value>(`https://jsonplaceholder.typicode.com/users/${id}`).subscribe(u => this.current.set(u));
}
}
bootstrapApplication(UsersComponent, { providers: [provideHttpClient(withFetch())] });
Lists 10 users from the public API. Click any name → details load. About 20 lines.
withFetch() uses the browser’s modern Fetch API — better Service Worker integration, supports streaming responses, plays nicely with SSR (Module 9). The legacy XHR backend is still there for compatibility but the Angular team treats withFetch() as the default going forward. Migration cost: zero — same API surface, just better internals.Lesson 6.2 picks up functional interceptors.

