Angular HttpClient Fundamentals: get, post, params, headers, observe Options [2026]

Link copied
Angular HttpClient Fundamentals: get, post, params, headers, observe Options [2026]

Angular HttpClient Fundamentals: get, post, params, headers, observe Options [2026]

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 #

Since v21, HttpClient is injectable by default. You still register provideHttpClient() in app.config.ts (lesson 1.6) as the place to add features such as interceptors:

import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),              // add features here, e.g. withInterceptors([...])
  ],
};

In Angular 22 the browser's fetch API is the default backend (FetchBackend). That makes withFetch() redundant: it is deprecated in v22 and can simply be deleted. If you need the old XMLHttpRequest backend, opt back in with withXhr(). The one difference that usually matters: the Fetch backend does not report upload progress events, so file-upload progress bars need provideHttpClient(withXhr()). Do not use withXhr() for SSR: XHR on the server is deprecated.

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:

  1. .get<T>() — type the response. The Observable emits T.
  2. The Observable emits ONCE with the response body, then completes. (Unlike RxJS Subjects, which are open-ended.)
  3. 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: 'Ada' });
this.http.put<User>('/api/users/42', { name: 'Ada 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: 'Ada', email: 'ada@example.com' });

HttpClient auto-detects the body type:

  • Plain object → application/json (stringified automatically)
  • FormData → multipart/form-data with boundary (don't set Content-Type manually)
  • URLSearchParams → application/x-www-form-urlencoded
  • Blob / ArrayBuffer → raw binary
  • string → 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, {
  reportUploadProgress: true,   // v22: replaces the deprecated reportProgress
  observe: 'events',            // emit ALL lifecycle events
}).subscribe(event => { /* check event.type */ });

v22 split the old reportProgress flag into reportUploadProgress and reportDownloadProgress. Upload progress requires the XHR backend (withXhr()); the default Fetch backend only reports download progress.

Three observe modes:

  • 'body' (default) — emit just the body
  • 'response' — emit the full HttpResponse (headers, status, body)
  • 'events' — emit every event (Sent, ResponseHeader, UploadProgress, DownloadProgress, 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
Interceptors never run Registered the interceptor but not via provideHttpClient(withInterceptors([...])) (HttpClient itself is injectable by default since v21) Add the feature to provideHttpClient()
Response body is string "..." instead of object Request set responseType: 'text', or the server double-encoded the JSON Drop responseType (JSON is the default and is parsed regardless of Content-Type), or fix the server
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 Set the timeout request option (ms), or pipe through RxJS timeout()
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 } 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()] });

Lists 10 users from the public API. Click any name → details load. About 20 lines.

YouShould I still add withFetch() to provideHttpClient()?
Claude · used search_documentationNo. In Angular 22 Fetch is already the default backend, so withFetch() is deprecated and can be deleted — same API surface, nothing else changes. The only reason to change backends is upload progress: Fetch does not emit upload progress events, so add withXhr() if you need them (but not for SSR, where XHR is deprecated — Module 9).

Lesson 6.2 picks up functional interceptors.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

1 comment

1 thought on “Angular HttpClient Fundamentals: get, post, params, headers, observe Options [2026]”

  1. Pingback: Angular Functional Interceptors: Auth, Retry, Logs [2026]

Leave a Comment

Your email stays private. Required fields are marked *

1 thought on “Angular HttpClient Fundamentals: get, post, params, headers, observe Options [2026]”

  1. Pingback: Angular Functional Interceptors: Auth, Retry, Logs [2026]

Leave a Comment

Your email stays private. Required fields are marked *