Angular HttpClient — GET, POST, PATCH, DELETE, Progress, and Typed Responses

Angular’s HttpClient is the built-in HTTP library that wraps the browser’s XMLHttpRequest and fetch APIs with an Observable-based interface. It provides strong TypeScript generics for typed responses, automatic JSON serialisation and deserialisation, request/response headers management, upload/download progress tracking, and seamless integration with the interceptor pipeline. A well-structured ApiService built on HttpClient is the foundation of every Angular application that communicates with a MEAN Stack backend. This lesson builds that service completely.

HttpClient Method Reference

Method Returns Use For
get<T>(url, options?) Observable<T> Fetch resources
post<T>(url, body, options?) Observable<T> Create resources
put<T>(url, body, options?) Observable<T> Replace resource
patch<T>(url, body, options?) Observable<T> Partial update
delete<T>(url, options?) Observable<T> Delete resource
request<T>(method, url, options?) Observable<HttpEvent<T>> Custom method or progress tracking

HttpClient Options Reference

Option Type Use For
headers HttpHeaders or object Custom request headers
params HttpParams or object URL query string parameters
observe 'body' | 'response' | 'events' What to include in the Observable value
responseType 'json' | 'text' | 'blob' | 'arraybuffer' Expected response format
reportProgress boolean Enable upload/download progress events
withCredentials boolean Send cookies cross-origin
context HttpContext Pass metadata to interceptors
Note: Angular’s HttpClient generic type parameter (get<T>) is a developer convenience for TypeScript type checking — it does not validate the runtime response against T. If the API returns data that does not match T, TypeScript will not throw at runtime. For production applications with complex API responses, consider using a validation library like Zod to validate the actual shape of the response at runtime, or rely on thorough API contract testing.
Tip: Build your ApiService as a typed thin wrapper around HttpClient with methods like get<T>(path), post<T>(path, body) etc. that automatically prepend the base URL, apply common headers, and map the response envelope ({ success, data }) to just the data property. Feature services (TaskService, UserService) call ApiService methods — not HttpClient directly. This means changing the base URL, adding a common header, or changing the response envelope structure only requires one change in ApiService.
Warning: Never subscribe to HttpClient calls without error handling. A 404, 401, or network error on an unhandled Observable will log an “unhandled error” to the console and can cause unexpected application behaviour if other code depends on the result. At minimum, pass an error handler to subscribe({ error: err => ... }). Better: use a global error interceptor (Chapter 12) and handle only the errors that require component-specific logic in the component.

Complete HttpClient Service Implementation

// core/services/api.service.ts — typed base HTTP service
import {
    Injectable, inject,
} from '@angular/core';
import {
    HttpClient, HttpParams, HttpHeaders, HttpContext,
    HttpRequest, HttpEventType, HttpEvent,
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { map, filter } from 'rxjs/operators';
import { API_BASE_URL }  from '../tokens';

export interface ApiResponse<T> {
    success: boolean;
    data:    T;
    meta?:   PaginationMeta;
}

export interface PaginationMeta {
    total:      number;
    page:       number;
    limit:      number;
    totalPages: number;
    hasNextPage:boolean;
    hasPrevPage:boolean;
}

export interface PaginatedResponse<T> {
    data: T[];
    meta: PaginationMeta;
    total: number;
}

@Injectable({ providedIn: 'root' })
export class ApiService {
    private http    = inject(HttpClient);
    private baseUrl = inject(API_BASE_URL);

    // ── Core typed methods ────────────────────────────────────────────────

    get<T>(path: string, params?: Record<string, string | number | boolean>): Observable<T> {
        const httpParams = params
            ? Object.entries(params).reduce(
                (p, [k, v]) => p.set(k, String(v)),
                new HttpParams()
              )
            : undefined;

        return this.http
            .get<ApiResponse<T>>(`${this.baseUrl}${path}`, { params: httpParams })
            .pipe(map(r => r.data));
    }

    getPaginated<T>(
        path: string,
        params?: Record<string, string | number | boolean>,
    ): Observable<PaginatedResponse<T>> {
        const httpParams = params
            ? Object.entries(params).reduce(
                (p, [k, v]) => p.set(k, String(v)),
                new HttpParams()
              )
            : undefined;

        return this.http
            .get<ApiResponse<T[]>>(`${this.baseUrl}${path}`, {
                params: httpParams,
                observe: 'response',  // get full response to read headers
            })
            .pipe(
                map(response => ({
                    data:  response.body?.data ?? [],
                    meta:  response.body?.meta ?? defaultMeta(),
                    total: parseInt(response.headers.get('X-Total-Count') ?? '0'),
                }))
            );
    }

    post<T>(path: string, body: unknown): Observable<T> {
        return this.http
            .post<ApiResponse<T>>(`${this.baseUrl}${path}`, body)
            .pipe(map(r => r.data));
    }

    put<T>(path: string, body: unknown): Observable<T> {
        return this.http
            .put<ApiResponse<T>>(`${this.baseUrl}${path}`, body)
            .pipe(map(r => r.data));
    }

    patch<T>(path: string, body: unknown): Observable<T> {
        return this.http
            .patch<ApiResponse<T>>(`${this.baseUrl}${path}`, body)
            .pipe(map(r => r.data));
    }

    delete<T = void>(path: string): Observable<T> {
        return this.http
            .delete<ApiResponse<T>>(`${this.baseUrl}${path}`)
            .pipe(map(r => r.data));
    }

    // ── File upload with progress tracking ───────────────────────────────
    uploadWithProgress<T>(
        path: string,
        formData: FormData,
    ): Observable<{ progress: number } | { result: T }> {
        const req = new HttpRequest('POST', `${this.baseUrl}${path}`, formData, {
            reportProgress: true,
        });

        return this.http.request<ApiResponse<T>>(req).pipe(
            filter(event =>
                event.type === HttpEventType.UploadProgress ||
                event.type === HttpEventType.Response
            ),
            map(event => {
                if (event.type === HttpEventType.UploadProgress) {
                    const progress = event.total
                        ? Math.round(100 * event.loaded / event.total)
                        : 0;
                    return { progress };
                }
                if (event.type === HttpEventType.Response) {
                    return { result: (event.body as ApiResponse<T>).data };
                }
                return { progress: 0 };
            })
        );
    }

    // ── Download file as blob ─────────────────────────────────────────────
    downloadBlob(path: string): Observable<Blob> {
        return this.http.get(`${this.baseUrl}${path}`, { responseType: 'blob' });
    }
}

function defaultMeta(): PaginationMeta {
    return { total: 0, page: 1, limit: 10, totalPages: 0, hasNextPage: false, hasPrevPage: false };
}

// ── Feature service using ApiService ─────────────────────────────────────
// core/services/task.service.ts

import { Injectable, inject } from '@angular/core';
import { Observable }          from 'rxjs';
import { ApiService, PaginatedResponse } from './api.service';
import { Task, CreateTaskDto, UpdateTaskDto, TaskQueryParams } from '../../shared/models/task.model';

@Injectable({ providedIn: 'root' })
export class TaskService {
    private api = inject(ApiService);

    getAll(params?: TaskQueryParams): Observable<PaginatedResponse<Task>> {
        return this.api.getPaginated<Task>('/tasks', params as any);
    }

    getById(id: string): Observable<Task> {
        return this.api.get<Task>(`/tasks/${id}`);
    }

    create(dto: CreateTaskDto): Observable<Task> {
        return this.api.post<Task>('/tasks', dto);
    }

    update(id: string, dto: UpdateTaskDto): Observable<Task> {
        return this.api.patch<Task>(`/tasks/${id}`, dto);
    }

    delete(id: string): Observable<void> {
        return this.api.delete(`/tasks/${id}`);
    }

    complete(id: string): Observable<Task> {
        return this.api.patch<Task>(`/tasks/${id}/complete`, {});
    }

    search(query: string): Observable<Task[]> {
        return this.api.get<Task[]>('/tasks/search', { q: query });
    }

    getStats(): Observable<TaskStats> {
        return this.api.get<TaskStats>('/tasks/stats');
    }

    uploadAttachment(
        taskId: string,
        file: File,
    ): Observable<{ progress: number } | { result: { url: string } }> {
        const form = new FormData();
        form.append('file', file);
        return this.api.uploadWithProgress(`/tasks/${taskId}/attachments`, form);
    }

    exportAsCsv(params?: TaskQueryParams): Observable<Blob> {
        return this.api.downloadBlob('/tasks/export');
    }
}

How It Works

Step 1 — HttpClient Returns a Cold Observable

Calling this.http.get('/api/tasks') does not make an HTTP request immediately — it returns a cold Observable. The request is only sent when subscribe() is called (or the async pipe subscribes). Each subscription creates a new HTTP request. To share a single HTTP request among multiple subscribers, pipe through shareReplay(1). The Observable completes after the response is received — it is a finite Observable, not an infinite stream.

Step 2 — observe: ‘response’ Gives Access to Headers

By default, http.get<T>(url) returns an Observable of the parsed response body (observe: 'body'). Using observe: 'response' returns an Observable of the full HttpResponse object, which includes the status code, headers, and body. This is how you access custom headers from your Express API — like X-Total-Count for pagination — which are not available when only observing the body.

Step 3 — HttpParams Builds Query Strings Type-Safely

HttpParams is an immutable class for building URL query strings. Each .set(key, value) call returns a new HttpParams instance. Angular serialises the params into the URL automatically. The advantage over string concatenation is type safety and proper URL encoding — special characters in values are encoded correctly, preventing URL injection. You can also use a plain object: { params: { page: '1', limit: '10' } }.

Step 4 — reportProgress: true Enables Upload/Download Progress Events

When reportProgress: true is set, the Observable emits multiple HttpEvent objects instead of just the final response: HttpSentEvent, HttpUploadProgressEvent, HttpDownloadProgressEvent, and finally HttpResponse. Each upload progress event contains loaded (bytes sent) and total (total bytes). Divide them and multiply by 100 to get a percentage. This powers progress bars for file uploads.

Step 5 — The ApiService Abstracts Away the Response Envelope

Your Express API wraps every response in { success: true, data: ... }. Without an ApiService, every service method must map through this envelope: map(r => r.data). Centralising this in ApiService.get<T>() means feature services (TaskService, UserService) deal only with the actual data types. If you ever change the response envelope shape, you update only ApiService — not 20 service methods spread across the codebase.

Common Mistakes

Mistake 1 — Not unsubscribing from HttpClient calls in components

❌ Wrong — if component is destroyed before response arrives, subscriber runs on destroyed component:

ngOnInit(): void {
    this.taskService.getById(this.id).subscribe(task => {
        this.task.set(task);   // component may be gone by now!
    });
}

✅ Correct — takeUntilDestroyed prevents stale updates:

ngOnInit(): void {
    this.taskService.getById(this.id)
        .pipe(takeUntilDestroyed())
        .subscribe(task => this.task.set(task));
}

Mistake 2 — Calling http.get() directly in components instead of services

❌ Wrong — components know about API URL and response shape:

export class TaskListComponent {
    private http = inject(HttpClient);
    ngOnInit() {
        this.http.get<{success: boolean; data: Task[]}>('http://localhost:3000/api/v1/tasks')
            .subscribe(r => this.tasks.set(r.data));
    }
}

✅ Correct — all HTTP through the service layer:

export class TaskListComponent {
    private taskService = inject(TaskService);
    ngOnInit() {
        this.taskService.getAll()
            .pipe(takeUntilDestroyed())
            .subscribe(({ data }) => this.tasks.set(data));
    }
}

Mistake 3 — Treating HttpParams as mutable

❌ Wrong — HttpParams is immutable — mutations are lost:

let params = new HttpParams();
params.set('page', '1');    // returns new instance — original unchanged!
params.set('limit', '10');  // same problem
this.http.get(url, { params });  // params is still empty!

✅ Correct — chain the immutable methods:

const params = new HttpParams()
    .set('page', '1')     // each .set() returns new HttpParams
    .set('limit', '10');  // chain from the previous return value
this.http.get(url, { params });  // params has both values

Quick Reference

Task Code
GET with query params http.get<T>(url, { params: { page: '1' } })
POST with body http.post<T>(url, body)
PATCH partial update http.patch<T>(url, partialBody)
DELETE http.delete<void>(url)
Full response with headers http.get<T>(url, { observe: 'response' })
Download blob http.get(url, { responseType: 'blob' })
Upload with progress new HttpRequest('POST', url, formData, { reportProgress: true })
Custom header http.get(url, { headers: { 'X-Custom': 'value' } })
HttpParams (typed) new HttpParams().set('key', 'value').set('key2', 'v2')
Map response envelope http.get<ApiResponse<T>>(url).pipe(map(r => r.data))

🧠 Test Yourself

A task list API returns { success: true, data: [...], meta: {...} } with an X-Total-Count header. What HttpClient option is needed to read both the body data AND the header value?