Skip to content
Angular ng http 4 min read

HTTP Error Handling

Network requests fail for all sorts of reasons: the server is down, the user is offline, a token expired, or the backend returned a validation error. A robust Angular app never lets those failures crash silently or surface a cryptic stack trace to the user. The HttpClient reports every failure as an HttpErrorResponse, and RxJS operators like catchError and retry let you recover, transform, or re-throw those errors in a predictable way. This page shows how to distinguish error categories, retry transient failures, and provide sensible fallbacks.

Understanding HttpErrorResponse

When a request fails, HttpClient pushes an HttpErrorResponse down the error channel of the observable. This object distinguishes two fundamentally different failure modes through its error property:

  • Client-side / network errors — the request never reached the server (DNS failure, offline, CORS, a thrown TypeError). Here error is an ErrorEvent (or a DOM ProgressEvent) and status is 0.
  • Server-side errors — the backend responded with a non-2xx status (404, 401, 500, …). Here error is the parsed response body and status holds the HTTP code.
import { HttpErrorResponse } from '@angular/common/http';

function describeError(err: HttpErrorResponse): string {
  if (err.status === 0) {
    // Client-side or network error.
    console.error('Network error:', err.error);
    return 'Could not reach the server. Check your connection.';
  }
  // Server returned an unsuccessful response code.
  console.error(`Backend returned ${err.status}`, err.error);
  return `Request failed (${err.status}). Please try again.`;
}

Output:

Backend returned 404 {message: "User not found"}

Catching errors in a service

Keep error handling close to the request. A service method should return a clean, typed stream and translate low-level HttpErrorResponse objects into something the component can consume. Use catchError from RxJS and throwError to re-emit a user-friendly error.

import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { catchError, Observable, throwError } from 'rxjs';

export interface User {
  id: number;
  name: string;
}

@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);
  private baseUrl = '/api/users';

  getUser(id: number): Observable<User> {
    return this.http.get<User>(`${this.baseUrl}/${id}`).pipe(
      catchError((err: HttpErrorResponse) => this.handleError(err)),
    );
  }

  private handleError(err: HttpErrorResponse) {
    const message =
      err.status === 0
        ? 'Network error — please check your connection.'
        : `Server error ${err.status}: ${err.error?.message ?? err.message}`;
    return throwError(() => new Error(message));
  }
}

Always return throwError(() => new Error(...)) (the factory form) rather than the value form. The factory defers creation until subscription, capturing an accurate stack trace.

Retrying transient failures

Many failures are temporary — a brief network blip or a rate-limited endpoint. The retry operator re-subscribes to the source observable, re-issuing the request. Used naively it hammers the server, so configure it with a delay and a cap, and only retry on errors worth retrying.

import { retry, timer } from 'rxjs';

getUserWithRetry(id: number): Observable<User> {
  return this.http.get<User>(`${this.baseUrl}/${id}`).pipe(
    retry({
      count: 3,
      delay: (error: HttpErrorResponse, retryCount) => {
        // Don't retry client errors (4xx) — they won't succeed on retry.
        if (error.status >= 400 && error.status < 500) {
          throw error;
        }
        // Exponential backoff: 500ms, 1000ms, 2000ms.
        return timer(2 ** (retryCount - 1) * 500);
      },
    }),
    catchError((err: HttpErrorResponse) => this.handleError(err)),
  );
}

The delay callback decides per-attempt whether to retry: throwing re-raises the error immediately, while returning a notifier observable (here a timer) schedules the next attempt after the delay elapses.

When to retry

Status / conditionRetry?Reason
0 (network)YesOften a transient connectivity blip
408 Request TimeoutYesServer was slow; a retry may succeed
429 Too Many RequestsYes*Respect Retry-After; back off before retrying
500, 502, 503, 504YesTransient server/infra failures
400, 401, 403, 404NoDeterministic — retrying won’t change the result

Providing a fallback

Sometimes you would rather degrade gracefully than propagate an error. Returning a default value with of(...) inside catchError keeps the stream alive so the UI can render placeholder content.

import { of } from 'rxjs';

getUsersSafe(): Observable<User[]> {
  return this.http.get<User[]>(this.baseUrl).pipe(
    catchError((err: HttpErrorResponse) => {
      console.error('Falling back to empty list', err);
      return of([]); // Component still receives a valid array.
    }),
  );
}

Surfacing errors in a component

In a standalone component, subscribe and route the error into a signal so the template can react with the new control-flow syntax.

import { Component, inject, signal } from '@angular/core';
import { UserService, User } from './user.service';

@Component({
  selector: 'app-user',
  standalone: true,
  template: `
    @if (error()) {
      <p class="error">{{ error() }}</p>
    } @else if (user()) {
      <h2>{{ user()!.name }}</h2>
    } @else {
      <p>Loading…</p>
    }
  `,
})
export class UserComponent {
  private service = inject(UserService);
  user = signal<User | null>(null);
  error = signal<string | null>(null);

  load(id: number) {
    this.error.set(null);
    this.service.getUser(id).subscribe({
      next: (u) => this.user.set(u),
      error: (e: Error) => this.error.set(e.message),
    });
  }
}

Centralize cross-cutting concerns — auth refresh on 401, global toasts, logging — in a functional interceptor instead of repeating logic in every service. See HTTP interceptors below.

Best practices

  • Always inspect status === 0 to separate network failures from server responses, and craft distinct user messages for each.
  • Handle errors in the service layer so components receive clean, typed streams and simple Error objects.
  • Use retry with count, exponential delay, and a guard that skips non-retryable 4xx errors.
  • Honor Retry-After headers for 429/503 instead of guessing the backoff window.
  • Prefer of(defaultValue) fallbacks when partial UI is better than a hard failure; re-throw when the caller genuinely needs to know.
  • Never swallow errors silently — log them (or report to an error-tracking service) even when you recover.
  • Move global error policies (logging, auth refresh, toasts) into a functional interceptor to avoid duplication.
Last updated June 14, 2026
Was this helpful?