Angular HTTP Client Tutorial: Consuming REST APIs, Interceptors & Error Handling

Every production Angular application communicates with a backend API. User authentication, data fetching, form submission, real-time updates - all of it passes through HTTP. Angular's HttpClient is the module that handles this communication, and it's significantly more powerful than the browser's native fetch or third-party libraries like axios. It's observable-based, type-safe, interceptable, and testable - all built in.

But most Angular tutorials stop at the basic GET request. Production applications need significantly more: typed HTTP responses so TypeScript can catch data shape errors at compile time, interceptors to attach authentication headers globally without touching every service, error handling strategies that distinguish between network failures and server errors, loading state management that keeps the UI responsive, and unit tests that verify HTTP behavior without hitting a real server. Understanding where Angular's HttpClient fits in the broader picture of frontend-backend communication is the right starting point - Board Infinity's Frontend and Backend in Web Development guide explains how APIs bridge the Angular frontend layer and backend services, and why the HTTP communication layer is so critical to get right.

This tutorial covers the complete Angular HttpClient picture. Every section includes working code that you can apply directly to your application. By the end, you'll have a robust HTTP layer that handles the full lifecycle of API communication - from request to response, through errors, interceptors, and tests.

Who This Guide Is For

This guide is for Angular developers who:

  • Know Angular basics (components, services, routing) and want to connect to a real backend
  • Have used HttpClient for basic GET requests but want to handle authentication, errors, and loading states properly
  • Want to write interceptors, typed services, and HTTP unit tests
  • Are building or joining a team that needs a consistent, scalable HTTP communication pattern
  • Want to understand how Angular's API communication model compares to other frameworks - Board Infinity's Angular vs React vs Vue guide covers how Angular's built-in HttpClient with Observables compares to the fetch-based approaches used in React and Vue ecosystems

1. Setting Up HttpClient

In Angular 21 with standalone architecture, HttpClient is provided via provideHttpClient() in app.config.ts. This replaces the older HttpClientModule.forRoot() approach from module-based Angular. Angular's decision to base HttpClient on Observables rather than Promises is one of its most significant design choices - to understand why Observables are more powerful for API communication, Board Infinity's guide on Promise in JavaScript explains the async primitive that Observables extend, and why HttpClient's multi-value, cancellable streams are a meaningful upgrade for complex HTTP scenarios.

TypeScript - HttpClient Setup (Standalone Angular 21)
// app.config.ts - register HttpClient for the entire application
import { ApplicationConfig } from '@angular/core';
import { provideRouter }     from '@angular/router';
import { provideHttpClient,
         withInterceptors }    from '@angular/common/http';
import { routes }               from './app.routes';
import { authInterceptor }      from './core/interceptors/auth.interceptor';
import { errorInterceptor }     from './core/interceptors/error.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(
withInterceptors([
authInterceptor,   // runs first - adds auth headers
errorInterceptor   // runs second - handles errors globally
])
)
]
};
๐Ÿ”
Interceptor Order Matters

Interceptors in the withInterceptors([]) array run in order for outgoing requests and in reverse order for incoming responses. If authInterceptor is first and errorInterceptor is second: requests flow auth โ†’ error โ†’ server, responses flow server โ†’ error โ†’ auth. Put authentication first so auth headers are attached before any other interceptor processes the request.

2. Making GET, POST, PUT, DELETE Requests

HttpClient provides a method for each HTTP verb: get(), post(), put(), patch(), delete(). Each returns an Observable that emits the response when subscribed. The response is not fetched until something subscribes to the Observable - this is the lazy evaluation model that makes HttpClient composable with RxJS operators. Understanding the full-stack API contract that Angular's CRUD methods implement is important context - Board Infinity's Essentials of Back-End Development guide covers how REST APIs are structured on the backend, which directly maps to the GET, POST, PUT, DELETE operations Angular's HttpClient calls. For developers who want to understand the backend that serves these HTTP calls, Board Infinity's guide on integrating Node.js APIs with Angular shows how the full MEAN stack connects Angular's HTTP layer to a Node.js backend.

TypeScript - Complete CRUD Service with HttpClient
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
// TypeScript interface - defines the shape of API response data
export interface Product {
id:          number;
name:        string;
price:       number;
categoryId:  number;
inStock:     boolean;
createdAt:   string;
}
export interface CreateProductDto {
name:       string;
price:      number;
categoryId: number;
}
export interface PaginatedResponse {
data:        T[];
total:       number;
page:        number;
pageSize:    number;
totalPages:  number;
}
@Injectable({ providedIn: 'root' })
export class ProductService {
private http    = inject(HttpClient);
private baseUrl = 'https://api.example.com/products';
// GET - with query parameters
getProducts(page = 1, pageSize = 20, category?: number):
Observable<PaginatedResponse<Product>> {
let params = new HttpParams()
  .set('page', page)
  .set('pageSize', pageSize);

if (category) params = params.set('category', category);

return this.http.get<PaginatedResponse<Product>>(this.baseUrl, { params });
}
// GET single item
getProduct(id: number): Observable<Product> {
return this.http.get<Product>(${this.baseUrl}/${id});
}
// POST - create
createProduct(dto: CreateProductDto): Observable<Product> {
return this.http.post<Product>(this.baseUrl, dto);
}
// PUT - full update
updateProduct(id: number, dto: CreateProductDto): Observable<Product> {
return this.http.put<Product>(${this.baseUrl}/${id}, dto);
}
// PATCH - partial update
patchProduct(id: number, changes: Partial<CreateProductDto>):
Observable<Product> {
return this.http.patch<Product>(${this.baseUrl}/${id}, changes);
}
// DELETE
deleteProduct(id: number): Observable<void> {
return this.http.delete<void>(${this.baseUrl}/${id});
}
}
โš ๏ธ
HttpClient Observables Are Cold - Always Subscribe (or Use async Pipe)

HttpClient methods return cold Observables - the HTTP request is NOT made until something subscribes. If you call this.http.get(url) in a service method but never subscribe in a component (or pipe through async), the request never fires. In components, use the async pipe in templates (preferred) or call .subscribe() in ngOnInit. Always unsubscribe when the component is destroyed - use takeUntilDestroyed() in Angular 16+ or the destroy$ Subject pattern.

3. Typed HTTP Responses with Interfaces

TypeScript's type safety only extends to your HTTP responses if you explicitly type them. The generic parameter on HttpClient methods - http.get<Product>() - tells TypeScript what shape the response will have. This enables compile-time error detection, IDE auto-completion, and safer refactoring.

The important caveat: TypeScript cannot verify API response shapes at runtime. If your API returns an object with a different shape than your interface, TypeScript won't catch it - the mismatch only becomes visible when your template tries to access a property that doesn't exist. This is why defining accurate, up-to-date interfaces is important, and why runtime validation libraries (like Zod) are increasingly used in production Angular apps. TypeScript's role in Angular's type-safe HTTP model is one of the key benefits of using Angular over vanilla JavaScript frameworks - its static typing catches entire categories of API data shape bugs before they reach production.

TypeScript - Typed Responses and Using Them in Components
// products-list.component.ts - uses typed service in a component
@Component({
  selector: 'app-products-list',
  standalone: true,
  imports: [NgFor, NgIf, AsyncPipe, ProductCardComponent],
  template: `
    <div *ngIf="loading">Loading products...</div>
<div *ngFor="let product of products$ | async">
  <app-product-card
    [product]="product"
    (deleteClicked)="onDelete(product.id)"
  />
</div>

<div *ngIf="error">{{ error }}</div>
`
})
export class ProductsListComponent implements OnInit {
private productService = inject(ProductService);
// Observable - template uses async pipe to subscribe/unsubscribe automatically
products$!: Observable<Product[]>;
loading   = false;
error     = '';
ngOnInit() {
this.loading   = true;
this.products$ = this.productService.getProducts().pipe(
map(response => response.data),  // extract data array from paginated response
tap(() => this.loading = false),  // side effect: clear loading on success
catchError(err => {
this.loading = false;
this.error   = 'Failed to load products. Please try again.';
return of([]); // return empty array so template has something to render
})
);
}
onDelete(productId: number) {
this.productService.deleteProduct(productId).subscribe({
next: () => console.log('Product deleted'),
error: (err) => console.error('Delete failed', err)
});
}
}

4. HTTP Interceptors: Adding Auth Headers Globally

HTTP interceptors are functions that run for every outgoing request and every incoming response. They're Angular's most powerful HTTP feature - they let you implement cross-cutting concerns (authentication headers, logging, caching, error handling) in one place without modifying individual service methods.

In Angular 21, interceptors are implemented as functional interceptors - plain functions that take the request and a next handler function. The interceptor pattern is a middleware concept that Angular implements elegantly in its HTTP layer - understanding how it compares to middleware in other environments is covered in Board Infinity's web development frameworks guide, which explains middleware patterns across Angular, Express, and other frameworks.

TypeScript - Auth Interceptor: Adding Bearer Token to Every Request
// core/interceptors/auth.interceptor.ts
import { inject }          from '@angular/core';
import { HttpInterceptorFn } from '@angular/common/http';
import { AuthService }      from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const token       = authService.getToken();
// Don't add auth header to public endpoints (login, register, public assets)
if (!token || req.url.includes('/auth/')) {
return next(req);
}
// Clone the request - HttpRequests are immutable, always clone to modify
const authenticatedReq = req.clone({
headers: req.headers.set('Authorization', Bearer ${token})
});
return next(authenticatedReq);
};
// core/interceptors/logging.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { tap } from 'rxjs/operators';
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
const startTime = Date.now();
console.log([HTTP] ${req.method} ${req.url});
return next(req).pipe(
tap({
next: (event) => {
if (event.type === HttpEventType.Response) {
const duration = Date.now() - startTime;
console.log([HTTP] ${event.status} in ${duration}ms);
}
}
})
);
};
๐Ÿ“Œ
Always Clone Requests - Never Mutate Them

HttpRequest objects are immutable by design. To modify a request (add headers, change the URL, append query params), you must clone it with req.clone() and pass the cloned version to next(). Attempting to directly modify request properties throws a runtime error. req.clone() accepts an options object with any properties you want to change - the rest are copied from the original.

5. Error Handling with catchError and retry

HTTP errors in Angular come in two flavors: client-side errors (network failure, timeout, CORS) and server-side errors (4xx, 5xx responses). Both arrive as HttpErrorResponse objects in the Observable's error channel.

The right approach is a two-layer error handling strategy: a global error interceptor that handles common cases (401 redirect to login, 500 generic error toast, network failure message), and service-level or component-level catchError handlers for errors that require specific behavior. The async error handling model that catchError implements in RxJS builds on the JavaScript error handling patterns covered in Board Infinity's JavaScript async and await guide - understanding Promise rejection and try/catch semantics makes catchError and throwError much more intuitive. For the deeper async patterns involved, Board Infinity's async/await in JavaScript guide covers how Angular's Observable error model compares to traditional Promise-based error handling.

TypeScript - Global Error Interceptor and Service-Level catchError
// core/interceptors/error.interceptor.ts - global error handler
import { inject }                                      from '@angular/core';
import { HttpInterceptorFn, HttpErrorResponse }       from '@angular/common/http';
import { catchError, throwError }                    from 'rxjs';
import { Router }                                       from '@angular/router';
import { NotificationService }                        from '../services/notification.service';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router              = inject(Router);
const notificationService = inject(NotificationService);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
  if (!navigator.onLine) {
    notificationService.error('No internet connection - please check your network');
    return throwError(() => error);
  }

  switch (error.status) {
    case 401:
      // Token expired or invalid - redirect to login
      router.navigate(['/login'], {
        queryParams: { returnUrl: router.url, reason: 'session-expired' }
      });
      break;

    case 403:
      notificationService.error('You do not have permission to perform this action');
      break;

    case 404:
      // Let individual services/components handle 404 - don't toast globally
      break;

    case 422:
      // Validation errors - let the form component handle the field errors
      break;

    case 500:
    default:
      notificationService.error('Something went wrong. Please try again.');
  }

  return throwError(() => error); // re-throw so components can also handle
})
);
};
// product.service.ts - retry strategy for flaky network requests
import { retry, catchError, throwError } from 'rxjs';
getProducts(): Observable<PaginatedResponse<Product>> {
return this.http.get<PaginatedResponse<Product>>(this.baseUrl).pipe(
// Retry up to 3 times with exponential backoff - for GET requests only
retry({
  count: 3,
  delay: (error, retryCount) => {
    // Don't retry 4xx errors - they're client errors, retrying won't help
    if (error.status >= 400 && error.status < 500) {
      return throwError(() => error);
    }
    const delayMs = Math.pow(2, retryCount) * 1000; // 2s, 4s, 8s
    console.log(`Retrying in ${delayMs}ms (attempt ${retryCount})`);
    return timer(delayMs);
  }
}),

catchError(err => throwError(() => err))
);
}
--- END HTML BLOCK --- --- HTML BLOCK: TIP BOX ---
โš ๏ธ
Never Retry Mutating Requests (POST, PUT, DELETE)

Retry strategies should only be applied to idempotent requests (GET, HEAD, OPTIONS). Retrying a POST request that creates a resource could result in duplicate records if the first request succeeded but the response was lost due to a network issue. Retrying a DELETE that partially succeeded could produce unexpected behavior. Only use retry() on GET requests. For write operations, show an error and let the user decide to try again.

6. Loading States and Skeletons

Loading state management is one of the most overlooked aspects of HTTP handling. Without explicit loading states, users see empty screens, stale data, or content that jumps around when responses arrive. There are two common patterns: a boolean isLoading flag, and a more robust state object that represents all possible states of an HTTP operation.

The AsyncState<T> pattern shown in this section - tracking data, loading, and error together - is a professional standard that scales cleanly to complex UIs. The relationship between HTTP loading states and the broader Angular architecture that powers them is one of the key reasons Angular has maintained strong adoption in enterprise applications - Board Infinity's state of Angular guide covers how Angular's mature tooling and patterns like this make it the preferred choice for teams building complex, data-intensive frontends.

TypeScript - Loading State Pattern with AsyncState
// Robust state type that covers all HTTP operation states
export interface AsyncState<T> {
  data:     T | null;
  loading:  boolean;
  error:    string | null;
}
const initialState = (): AsyncState => ({
data: null, loading: false, error: null
});
// products-page.component.ts - using AsyncState pattern
@Component({
selector: 'app-products-page',
standalone: true,
imports: [NgIf, NgFor, ProductCardComponent, SkeletonComponent],
template: `

<!-- Error state --> <div *ngIf="products.error" class="error-banner"> {{ products.error }} <button (click)="loadProducts()">Try Again</button> </div> <!-- Success state --> <div *ngIf="products.data && !products.loading" class="products-grid"> <app-product-card *ngFor="let product of products.data" [product]="product" /> </div> `
}) export class ProductsPageComponent implements OnInit { private productService = inject(ProductService); products: AsyncState<Product[]> = initialState(); ngOnInit() { this.loadProducts(); } loadProducts() { this.products = { ...this.products, loading: true, error: null }; this.productService.getProducts().pipe( map(response => response.data) ).subscribe({ next: (data) => { this.products = { data, loading: false, error: null }; }, error: (err) => { this.products = { data: null, loading: false, error: 'Failed to load products. Please try again.' }; } }); } }

7. Testing HTTP Calls with HttpClientTestingModule

Unit testing HTTP calls in Angular requires intercepting the HTTP requests made by your service and returning mock responses - without hitting a real server. Angular's HttpClientTestingModule provides HttpTestingController for exactly this purpose. Angular's exceptional built-in testing infrastructure for HTTP - including HttpClientTestingModule, HttpTestingController, and TestBed - is one of the framework's strongest differentiators compared to React and Vue. For developers coming from a JavaScript background who want to understand why testability matters so deeply in professional Angular development, Board Infinity's JavaScript trends and tools guide covers how testing culture has evolved across the JavaScript ecosystem and why Angular's test-first design philosophy stands out.

TypeScript - Testing HTTP Services with HttpClientTestingModule
// product.service.spec.ts
import { TestBed }                            from '@angular/core/testing';
import { HttpClientTestingModule,
         HttpTestingController }                from '@angular/common/http/testing';
import { ProductService, Product }            from './product.service';
describe('ProductService', () => {
let service:    ProductService;
let httpMock:   HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports:   [HttpClientTestingModule],  // replaces real HttpClient with mock
providers: [ProductService]
});
service  = TestBed.inject(ProductService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // ensures no unexpected HTTP requests were made
});
it('should GET products and return typed array', () => {
const mockProducts: Product[] = [
{ id: 1, name: 'Widget', price: 9.99, categoryId: 1, inStock: true, createdAt: '2026-01-01' },
{ id: 2, name: 'Gadget', price: 19.99, categoryId: 2, inStock: false, createdAt: '2026-01-02' }
];
let result: Product[] = [];

service.getProducts().subscribe(response => {
  result = response.data;
});

// Assert that a GET request was made to the right URL
const req = httpMock.expectOne(r =>
  r.method === 'GET' && r.url === 'https://api.example.com/products'
);

// Flush mock response - triggers the Observable
req.flush({ data: mockProducts, total: 2, page: 1, pageSize: 20, totalPages: 1 });

expect(result.length).toBe(2);
expect(result[0].name).toBe('Widget');
});
it('should handle 404 error gracefully', () => {
let errorReceived: any;
service.getProduct(999).subscribe({
  next:  () => fail('Expected error, got success'),
  error: (err) => errorReceived = err
});

const req = httpMock.expectOne('https://api.example.com/products/999');
req.flush('Not found', { status: 404, statusText: 'Not Found' });

expect(errorReceived.status).toBe(404);
});
it('should send POST with correct body', () => {
const newProduct = { name: 'New Widget', price: 29.99, categoryId: 1 };
service.createProduct(newProduct).subscribe();

const req = httpMock.expectOne('https://api.example.com/products');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newProduct);
req.flush({ id: 42, ...newProduct, inStock: true, createdAt: '2026-01-01' });
});
});
๐Ÿ’ก
Always Call httpMock.verify() in afterEach

httpMock.verify() in afterEach checks that no HTTP requests were made that you didn't explicitly expect in your test. Without it, a service accidentally making an extra HTTP request would go unnoticed. It's also the right place to call TestBed.resetTestingModule() for clean test isolation. These two lines in every afterEach prevent flaky tests caused by shared HTTP state between test cases.

Further Reading

Board Infinity Guides:

External Resources:

๐Ÿš€ Master Angular HTTP and APIs - With Real Projects

Angular Foundation & Application Architecture on Coursera

This free Coursera course by Board Infinity applies every HTTP concept in this guide through a complete, real Angular project. Module 1 of the intermediate course is dedicated entirely to HttpClient, API integration patterns, interceptors, error handling, and building API-driven features with smart and presentational components.

Module 1
HTTP, APIs & Data Handling HttpClient deep dive (GET, POST, PUT, DELETE), error handling and retry strategies, HTTP interceptors for auth and logging, pagination and filtering APIs, and API-driven CRUD features
Module 2
Intermediate State Management & Reactivity RxJS Observables, Subjects and BehaviorSubjects, pipeable operators, Angular Signals, signal-based data flow, and stateful service patterns
Module 3
Component Design Patterns & Architecture Smart vs presentational components, dependency injection mastery, injection tokens, feature-based architecture, and scalable folder structures
Module 4
Testing Angular Applications Jasmine and Karma unit testing, testing pipes, services and HTTP calls with HttpClientTestingModule, component fixture testing, and testing standalone components
Start Learning Angular on Coursera โ†’

โœ“ Certificate available  ยท  โœ“ Self-paced  ยท  โœ“ Real project milestones

Conclusion

Angular's HttpClient is a complete HTTP communication system - not just a wrapper around fetch. Typed generic parameters bring TypeScript's type safety to API responses. Functional interceptors provide a centralized, reusable layer for authentication, logging, and error handling. catchError and retry give you fine-grained control over failure scenarios. Explicit loading state management keeps the UI honest about what's happening. And HttpClientTestingModule makes every HTTP interaction verifiable without a real server.

The architectural pattern to internalize: all HTTP communication belongs in injectable services. Components receive data through service Observables and the async pipe - they don't call HttpClient directly. Interceptors handle cross-cutting concerns. Error handling happens at two levels: globally in interceptors for common cases (401, 500), and locally in components for scenario-specific behavior (showing inline validation errors for 422 responses). For developers who want to complete the full-stack picture - understanding not just how Angular calls APIs but how those APIs are built and structured - Board Infinity's How to Become a Backend Developer guide is the natural companion to this Angular HTTP tutorial.

With HttpClient fully understood, the natural next steps are RxJS operators for transforming and combining API streams, Angular Signals for reactive state management, and the NgRx ecosystem for complex state that needs to be shared across multiple unrelated components. All of these build directly on the observable foundation that HttpClient establishes.

Web Development Angular REST API HTTP