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
HttpClientfor 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
HttpClientwith 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.
// 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 ]) ) ] };
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.
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 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.
// 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.
// 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); } } }) ); };
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.
// 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)) ); }
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.
// 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.
// 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' }); }); });
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:
- Angular vs AngularJS - Key Differences Explained
- Benefits of Using Angular for Enterprise Development
- The State of Angular in 2022 - Relevance vs Legacy
- Angular vs React vs Vue - Which Framework to Choose
- Web Development Frameworks - A Complete Comparison
- Integrating Node.js with Angular
- Essentials of Back-End Development: From APIs to Databases
- Frontend and Backend in Web Development
- How to Become a Backend Developer (Roadmap)
- How to Become a Front-End Developer (Roadmap)
- Promise in JavaScript
- Promise.all() in JavaScript
- JavaScript Async and Await Function
- Async/Await in JavaScript Explained
- JavaScript Trends and Tools
External Resources:
- Angular Official Docs - HttpClient
- Angular Official Docs - HTTP Interceptors
- RxJS Official Documentation - catchError
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.
โ 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.