Tutorials

The 5 Minute Guide to Making HTTP Requests in Angular

Key Takeaways

  • Configure HTTP with provideHttpClient(), rather than starting new code with the deprecated HttpClientModule.
  • Keep endpoint URLs and HTTP operations in a service; let components handle presentation and user actions.
  • Consume request Observables with the async pipe or an explicit subscription, and avoid accidental duplicate subscriptions.
  • Match your response types to the API contract, but validate untrusted data separately at runtime.
  • Give failures an intentional path: report them safely, preserve useful error information, and show an actionable UI state.

To make HTTP requests in Angular, inject HttpClient into a service and return typed Observables from its methods. Components consume those results through the async pipe or a subscription. That separation keeps networking reusable and your UI easier to test.

This Angular HttpClient tutorial uses one consistent users API for GET, POST, PUT, and DELETE. You'll need an existing Angular application and a backend implementing the contract below; the examples don't create a server.

Set Up Angular HttpClient

Current Angular provides HttpClient for injection by default. An explicit provideHttpClient() call remains the configuration point for HTTP features and also supports applications that require explicit registration. Angular's HTTP setup guide covers both standalone and NgModule bootstrap.

In a standalone application, merge these providers into your existing configuration rather than removing router or other application providers:

// src/app/api-config.ts
import { InjectionToken } from '@angular/core';

export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL', {
  providedIn: 'root',
  factory: () => '/api',
});

// src/app/app.config.ts
import type { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient()],
};

// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';

bootstrapApplication(AppComponent, appConfig).catch(console.error);

The base URL lives in one place. To target a different API, override API_BASE_URL in application providers with a deployment-specific value, such as { provide: API_BASE_URL, useValue: '/backend' }. An InjectionToken gives Angular a runtime key for this configuration value; it isn't a place to store secrets.

Define the API Contract and User Type

For this example, assume the backend follows this contract:

OperationEndpointSuccessful response body
GET/api/usersA bare User[] array
POST/api/usersThe created User, including its ID
PUT/api/users/:idThe updated User
DELETE/api/users/:idNo body, with status 204

Don't alternate between an array and an object containing a users property. Your types and component must agree with the actual response.

// src/app/user.model.ts
export interface User {
  id: string;
  first: string;
  last: string;
  email: string;
}

export type UserInput = Omit<User, 'id'>;

UserInput describes the writable fields without the server-assigned ID. TypeScript's Omit utility type constructs that type from User.

Create a Typed HttpClient Service

Keep URLs, request construction, and shared failure reporting in the service. This is the same organizational principle covered in our earlier Angular services tutorial; components shouldn't duplicate API access logic.

The HttpClient methods below return response-body Observables. @Injectable({ providedIn: 'root' }) registers the service with the root injector, so you don't need to add it to each component's providers.

// src/app/user.service.ts
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
import type { Observable } from 'rxjs';
import { API_BASE_URL } from './api-config';
import type { User, UserInput } from './user.model';

@Injectable({ providedIn: 'root' })
export class UserService {
  private readonly http = inject(HttpClient);
  private readonly usersUrl = `${inject(API_BASE_URL)}/users`;

  getUsers(): Observable<User[]> {
    return this.http.get<User[]>(this.usersUrl).pipe(
      catchError(error => this.handleError(error)),
    );
  }

  createUser(input: UserInput): Observable<User> {
    return this.http.post<User>(this.usersUrl, input).pipe(
      catchError(error => this.handleError(error)),
    );
  }

  updateUser(id: string, input: UserInput): Observable<User> {
    const url = `${this.usersUrl}/${encodeURIComponent(id)}`;
    return this.http.put<User>(url, input).pipe(
      catchError(error => this.handleError(error)),
    );
  }

  deleteUser(id: string): Observable<void> {
    const url = `${this.usersUrl}/${encodeURIComponent(id)}`;
    return this.http.delete<void>(url).pipe(
      catchError(error => this.handleError(error)),
    );
  }

  private handleError(error: unknown): Observable<never> {
    if (error instanceof HttpErrorResponse) {
      // Replace with your application's telemetry in production.
      console.warn('User API request failed', { status: error.status });
    }
    return throwError(() => error);
  }
}

GET Requests: Consume Results in a Component

An HttpClient Observable is cold: creating it doesn't send the request. Subscribing sends it, and subscribing again generally sends another request. If this model is new to you, our Observable versus Promise guide explains the distinction.

For template data, the async pipe subscribes, exposes the latest value, and unsubscribes when the component is destroyed. Store the Observable in a field rather than calling getUsers() repeatedly from the template.

This component loads users and includes the mutation handlers discussed next. It deliberately distinguishes a failed load from a successful empty list:

// src/app/app.component.ts
import { AsyncPipe } from '@angular/common';
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { catchError, of } from 'rxjs';
import type { Observable } from 'rxjs';
import { UserService } from './user.service';
import type { User } from './user.model';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [AsyncPipe],
  template: `
    <button (click)="postUser()">Create Sam</button>
    <p role="status">{{ mutationMessage() }}</p>

    @if (users$ | async; as users) {
      @if (loadError()) {
        <p role="alert">{{ loadError() }}</p>
      } @else {
        <ul>
          @for (user of users; track user.id) {
            <li>
              {{ user.first }} {{ user.last }}
              <button (click)="putUser(user)">Rename to Samuel</button>
              <button (click)="deleteUser(user.id)">Delete</button>
            </li>
          } @empty {
            <li>No users found.</li>
          }
        </ul>
      }
    } @else {
      <p>Loading users…</p>
    }
  `,
})
export class AppComponent {
  private readonly userService = inject(UserService);
  private readonly destroyRef = inject(DestroyRef);
  readonly loadError = signal('');
  readonly mutationMessage = signal('');
  users$ = this.loadUsers();

  postUser(): void {
    this.write(this.userService.createUser({
      first: 'Sam', last: 'Smith', email: 'sam@example.com',
    }), 'User created.');
  }

  putUser(user: User): void {
    this.write(this.userService.updateUser(user.id, {
      first: 'Samuel', last: user.last, email: user.email,
    }), 'User updated.');
  }

  deleteUser(id: string): void {
    this.write(this.userService.deleteUser(id), 'User deleted.');
  }

  private loadUsers(): Observable<User[]> {
    this.loadError.set('');
    return this.userService.getUsers().pipe(
      catchError(() => {
        this.loadError.set('Could not load users. Please try again.');
        return of<User[]>([]);
      }),
    );
  }

  private write(request$: Observable<unknown>, message: string): void {
    this.mutationMessage.set('');
    request$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
      next: () => {
        this.mutationMessage.set(message);
        this.users$ = this.loadUsers();
      },
      error: () => this.mutationMessage.set(
        'Request failed. Your changes were not confirmed.',
      ),
    });
  }
}

HTTP Observables normally complete after the response. Cleanup still matters: it prevents callbacks from acting on destroyed components, and unsubscribing aborts an in-progress request. Long-lived streams need lifecycle-aware cleanup too.

The mutation helper uses takeUntilDestroyed with an explicit DestroyRef because event-handler methods run outside an injection context. Cancelling a client request does not guarantee that the server rolls back a write already received.

POST Requests: Create a User

createUser(input) sends the writable fields to the collection endpoint. HttpClient serializes a plain-object request body as JSON; you don't need an empty options object or a manual JSON.stringify() call.

The component's postUser() passes a Sam Smith object and subscribes through write(). Under our example contract, success returns the created user with its ID. The component then reloads the list; merely calling the service method without consuming its Observable would send nothing.

PUT Requests: Replace the Resource's Writable State

Include the resource ID in the URL: /api/users/42, not just /api/users. The example's putUser() changes the first name while sending every writable field required by this API.

PUT replaces the target resource's representation; PATCH applies partial modifications. If the backend supports partial updates, use http.patch() with its documented payload format. Angular doesn't decide whether omitted fields are preserved—your API contract does.

DELETE Requests: Target a User by ID

deleteUser(id) constructs the resource URL and returns Observable<void> because this example API returns no body. In the component, the Delete button invokes the service through write(), which reports success and reloads users.

Don't assume every DELETE endpoint has the same response shape. If yours returns a confirmation object, give that object an appropriate response type instead.

Handle HttpClient Errors Intentionally

Angular reports request failures through the Observable error channel as HttpErrorResponse. Network and timeout failures have status 0; backend failures carry the HTTP status returned by the server.

The service composes error handling with pipe() and catchError. Its handler accepts the error, reports only the status, and rethrows the original object. The arrow callback preserves the service's this context.

Import catchError and throwError from rxjs. Use the factory form throwError(() => error), rather than the deprecated direct-value form, as documented in the RxJS implementation and API comments.

The component owns user-facing recovery. Its GET fallback returns an empty array and sets a separate error state, so an outage doesn't look like an empty database. Mutation errors remain failures; don't turn them into success messages or blindly retry writes.

HTTP Options: Parameters, Headers, and Full Responses

If your API supports search and limit parameters, add this method to UserService. Merge the additional imports into the existing file:

// Fragment: additional imports and a UserService method.
import { HttpHeaders, HttpParams } from '@angular/common/http';
import type { HttpResponse } from '@angular/common/http';

// Merge this method into the existing UserService class:
export class UserService {
  // ... existing fields and methods from above

  searchUsers(term: string): Observable<HttpResponse<User[]>> {
    const params = new HttpParams().set('search', term).set('limit', 20);
    const headers = new HttpHeaders().set('Accept', 'application/json');

    return this.http.get<User[]>(this.usersUrl, {
      params,
      headers,
      observe: 'response',
    }).pipe(catchError(error => this.handleError(error)));
  }
}

HttpParams and HttpHeaders are immutable: methods such as set() return new instances. Keep the returned value rather than expecting the original object to change.

With observe: 'response', the emitted value is HttpResponse<User[]>, not User[]. Read response.status, response.headers.get('ETag'), and response.body ?? []; the body can be null. If you extract options into a variable, preserve the literal type with observe: 'response' as const.

HttpClient Configuration and Interceptors

HttpClient isn't inherently tied to XMLHttpRequest. Current Angular uses the Fetch API by default, as described in the provideHttpClient reference.

Component / template -- consumes --> UserService
                                      |
                                      v
                              HttpClient / interceptors --> API
                                      |
                       response or error Observable
                                      |
                                      v
                              Component / template

Interceptors are middleware for shared request and response behavior, such as API-scoped authentication headers or timing. Register new functional interceptors with withInterceptors(); withInterceptorsFromDi() integrates existing class-based interceptors.

Keep endpoint-specific operations in services and cross-cutting behavior in interceptors. Our Angular HTTP interceptor guide provides additional background; prefer the functional registration described here for new code.

Test the Service Without a Real Backend

Angular's HTTP testing utilities replace the network backend. When supplying both functions, register provideHttpClient() before provideHttpClientTesting().

In an Angular project configured for Vitest, save this alongside the service:

// src/app/user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import {
  HttpTestingController,
  provideHttpClientTesting,
} from '@angular/common/http/testing';
import { firstValueFrom } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { UserService } from './user.service';
import type { User } from './user.model';

describe('UserService', () => {
  let service: UserService;
  let http: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(), provideHttpClientTesting()],
    });
    service = TestBed.inject(UserService);
    http = TestBed.inject(HttpTestingController);
  });

  afterEach(() => http.verify());

  it('loads the users array', async () => {
    const users: User[] = [{
      id: '42', first: 'Sam', last: 'Smith', email: 'sam@example.com',
    }];
    const result = firstValueFrom(service.getUsers());
    const request = http.expectOne({ method: 'GET', url: '/api/users' });
    request.flush(users);
    expect(await result).toEqual(users);
  });

  it('preserves backend errors', async () => {
    const result = firstValueFrom(service.getUsers());
    const failure = expect(result).rejects.toMatchObject({ status: 500 });
    http.expectOne('/api/users').flush(
      { message: 'Unavailable' },
      { status: 500, statusText: 'Internal Server Error' },
    );
    await failure;
  });
});

firstValueFrom() subscribes and starts the request. expectOne() checks that exactly one matching request exists, flush() supplies a success or error response, and verify() checks for unmatched requests. For mutations, also assert the request body and resource URL.

Common Mistakes

  • Duplicate requests: Don't subscribe manually to a stream that the template also consumes unless you intend two requests.
  • Duplicate submissions: Disable or guard mutation actions while they're pending; the compact demo above doesn't implement that UI policy.
  • Hidden failures: Keep error state separate from successful empty data, and don't display raw server error bodies to users.
  • Unverified contracts: Test response shapes and validate external data where trust boundaries require it.

The maintainable pattern is straightforward: configure HTTP centrally, keep typed operations in services, consume each request deliberately, and make both success and failure visible in the component. Add tests around that boundary before expanding the API.

FAQ

How do I make an HTTP GET request in Angular?

Inject HttpClient into a service and return http.get<User[]>(url). Consume the returned Observable with the async pipe or subscribe(); creating the Observable alone does not send the request.

Should I use HttpClientModule or provideHttpClient?

Use provideHttpClient() for provider-based HTTP configuration in standalone and NgModule applications. HttpClientModule is deprecated; when migrating existing applications, preserve their interceptor configuration.

How do I handle HttpClient errors in Angular?

Use catchError in an Observable pipeline and choose whether to recover or rethrow with throwError(() => error). Inspect HttpErrorResponse: status 0 indicates a network or timeout failure, while backend failures carry the server's HTTP status.

Does HttpClient's TypeScript generic validate the JSON response?

No. The generic declares the expected response type for compile-time checking; it does not validate the server's JSON. Request unknown and apply runtime validation when the response cannot be trusted.

How do I test an Angular HttpClient service?

Configure provideHttpClientTesting(), then use HttpTestingController to expect requests and flush mock responses. Test successful bodies and failure paths, and call verify() after each test to catch unmatched requests.

Further Reading

Written by Sam Erickson

Software engineer. I write every article on StackChief myself. Author of Mastering the Tech Interview. More about me