Angular HTTP Error Handling and Retry: Patterns for Resilient Apps [2026]
Network requests fail. The endpoint is down for ten seconds; the user lost WiFi; the server returned 500 because the database hiccupped. A polished app degrades gracefully instead of showing a generic "oops" toast. This lesson walks the layered approach: per-request error handling, global error normalization, retry-with-backoff strategies, and user-facing error UI patterns.
This is lesson 6.4 of the Angular Tutorial. You should have read lessons 6.1-6.3 on HttpClient, interceptors, and httpResource.
Three layers of error handling #
| Layer | Where it lives | What it does |
|---|---|---|
| Global | Interceptor + global ErrorHandler | Normalize error shapes, log, send to Sentry |
| Per-resource | httpResource.error() signal in components |
Show error UI per-feature |
| Per-call | catchError in RxJS pipe |
One-off transformations |
Good apps use all three. Global handles the cross-cutting concerns (logging, normalization), per-resource handles UI, per-call is the escape hatch for one-off cases.
Error shapes from HttpClient #
Failed requests reject with an HttpErrorResponse:
interface HttpErrorResponse {
status: number; // HTTP status (e.g. 404, 500)
statusText: string; // "Not Found", "Internal Server Error"
error: any; // server's response body (JSON-parsed if possible)
url: string | null; // the requested URL
ok: false;
}
Network failures (DNS, CORS, offline) emit with status: 0. The error field contains the error event.
Pattern 1: Global error normalization (interceptor) #
Lesson 6.2 introduced this; the realistic version:
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
export class AppError extends Error {
constructor(
public status: number,
message: string,
public details?: any,
public correlationId?: string,
) {
super(message);
this.name = 'AppError';
}
}
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
const correlationId = req.headers.get('X-Trace-ID') ?? undefined;
let message: string;
switch (err.status) {
case 0: message = 'Network unavailable. Check your connection.'; break;
case 401: message = 'Your session has expired. Please sign in again.'; break;
case 403: message = 'You don\'t have permission to do that.'; break;
case 404: message = 'The requested resource was not found.'; break;
case 429: message = 'Too many requests. Try again in a moment.'; break;
case 503: message = 'The service is temporarily unavailable.'; break;
default:
message = err.error?.message ?? err.statusText ?? 'Something went wrong.';
}
return throwError(() => new AppError(err.status, message, err.error, correlationId));
}),
);
};
Now every component sees AppError with human-readable message and consistent status. Different servers' error shapes don't matter — they're normalized at the boundary.
Pattern 2: Global ErrorHandler for uncaught errors #
When an error escapes all your component-level handling, the framework's ErrorHandler catches it. Override it:
import { ErrorHandler, inject } from '@angular/core';
import { AppError } from './error-types';
import { ToastService } from './toast.service';
export class AppErrorHandler implements ErrorHandler {
private toast = inject(ToastService);
handleError(error: unknown) {
if (error instanceof AppError) {
this.toast.show(error.message);
// Optional: send to Sentry / Datadog with correlationId
} else {
console.error('Unexpected error:', error);
this.toast.show('An unexpected error occurred.');
}
}
}
// app.config.ts
providers: [
{ provide: ErrorHandler, useClass: AppErrorHandler },
]
This catches anything that bubbles up: thrown exceptions in templates, unhandled Promise rejections from effect() callbacks, async errors not caught by httpResource.
Pattern 3: Per-resource error UI #
For errors that should show inline (not as a toast), read from the resource's error signal:
export class UsersListComponent {
users = httpResource<User[]>(() => ({ url: '/api/users' }));
}
@if (users.isLoading()) {
<p>Loading users...</p>
} @else if (users.error(); as err) {
<div class="error-banner">
<p>{{ err.message }}</p>
<button (click)="users.reload()">Retry</button>
</div>
} @else {
<ul>@for (u of users.value() ?? []; track u.id) { <li>{{ u.name }}</li> }</ul>
}
The interceptor normalized the error to AppError, the component reads its message (templates have no as casts — AppError extends Error, so .message type-checks as is), and the user gets a retry button. No generic "Oops" — they know what failed.
Pattern 4: Retry with exponential backoff #
For transient errors (network blips, 5xx), retry automatically before showing an error:
import { HttpInterceptorFn } from '@angular/common/http';
import { retry, timer, throwError } from 'rxjs';
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
// Only retry idempotent verbs
if (req.method !== 'GET' && req.method !== 'HEAD') return next(req);
return next(req).pipe(
retry({
count: 3,
delay: (error, retryCount) => {
// Don't retry 4xx (client errors don't fix themselves)
if (error.status >= 400 && error.status < 500) {
return throwError(() => error);
}
// Exponential backoff: 250ms, 500ms, 1000ms
const delay = Math.min(250 * 2 ** (retryCount - 1), 5000);
return timer(delay);
},
}),
);
};
The retry operator subscribes to the source again — for HttpClient, that means a fresh request. Wrap with tap to log retries:
retry({
count: 3,
delay: (err, n) => {
console.warn(`Retry ${n} after ${err.status}`);
return timer(250 * 2 ** (n - 1));
},
})
Pattern 5: Don't retry mutations #
POST/PUT/PATCH/DELETE are often non-idempotent. Retrying could duplicate the operation. The retryInterceptor above guards by checking req.method. For POSTs that ARE safe to retry (uploads, idempotent creates with client-generated IDs), let them through:
import { HttpContext, HttpContextToken } from '@angular/common/http';
export const SAFE_TO_RETRY = new HttpContextToken<boolean>(() => false);
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
const safe = req.context.get(SAFE_TO_RETRY) || ['GET', 'HEAD'].includes(req.method);
if (!safe) return next(req);
return next(req).pipe(retry({ count: 3, delay: /* ... */ }));
};
// At a safe call site
this.http.post('/api/uploads', form, {
context: new HttpContext().set(SAFE_TO_RETRY, true),
});
Pattern 6: User-friendly error messages #
The interceptor's switch-on-status is the foundation, but for richer messaging, parse the server's error body. Common shapes:
// 422 Validation Error — show field-specific errors
if (err.status === 422 && err.error?.errors) {
// err.error.errors = { email: 'Already taken' }
return throwError(() => new ValidationError(err.error.errors));
}
// 409 Conflict — usually "this item was modified by someone else"
if (err.status === 409) {
return throwError(() => new AppError(409, 'This was modified by someone else. Please refresh.'));
}
A dedicated ValidationError lets form components show errors next to the offending field.
Pattern 7: Offline detection #
navigator.onLine is a hint, not gospel. Combine with HttpClient retries:
import { catchError, fromEvent, switchMap, take, throwError } from 'rxjs';
export const offlineInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((err) => {
if (err.status === 0 && !navigator.onLine) {
// Wait for network to come back
return fromEvent(window, 'online').pipe(
take(1),
switchMap(() => next(req)), // retry once back online
);
}
return throwError(() => err);
}),
);
};
When the user comes back online, requests that failed during offline replay automatically. Combine with a toast: "Reconnecting… your changes will save shortly."
Common gotchas #
| Symptom | Cause | Fix |
|---|---|---|
| Errors not normalized | Interceptor isn't in withInterceptors([...]) |
Add it |
| Retry loops forever | No max count OR retry condition wrong | retry({ count: 3, delay: ... }) and ensure 4xx skips retry |
| Toast doesn't fire | ErrorHandler not registered | Add { provide: ErrorHandler, useClass: AppErrorHandler } |
| POST retried and duplicated | Retry policy retries all verbs | Guard with if (req.method !== 'GET') |
err.error is a string instead of JSON |
Server returned a non-JSON error body (HTML error page, plain text) | When the body can't be parsed as JSON, error holds the raw text — handle both shapes |
| 401 doesn't redirect to login | 401 handler is too late in the chain | Use a dedicated auth-401 interceptor BEFORE the generic error interceptor |
What's next #
Lesson 6.5 covers caching strategies — using httpResource + a cache layer for stale-while-revalidate. Lesson 6.6 closes Module 6 with server-sent events for streaming HTTP.
Try it yourself #
A full error-handling stack — normalize, retry, surface to user:
import { Component } from '@angular/core';
import { JsonPipe } from '@angular/common';
import { provideHttpClient, withInterceptors, HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { catchError, retry, throwError, timer } from 'rxjs';
import { httpResource } from '@angular/common/http';
import { bootstrapApplication } from '@angular/platform-browser';
class AppError extends Error {
constructor(public status: number, message: string) { super(message); }
}
const errorAndRetryInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
retry({
count: 2,
delay: (err: HttpErrorResponse, n) => err.status >= 500 ? timer(500 * n) : throwError(() => err),
}),
catchError((err: HttpErrorResponse) => {
const msg = err.status === 0 ? 'Network offline' : err.error?.message ?? err.statusText;
return throwError(() => new AppError(err.status, msg));
}),
);
};
@Component({
selector: 'app-demo',
imports: [JsonPipe],
template: `
@if (data.isLoading()) { <p>Loading...</p> }
@else if (data.error(); as err) { <p>Error: {{ err.message }} <button (click)="data.reload()">Retry</button></p> }
@else { <pre>{{ data.value() | json }}</pre> }
`,
})
class DemoComponent {
data = httpResource<unknown>(() => ({ url: 'https://nonexistent-domain.invalid/api' }));
}
bootstrapApplication(DemoComponent, { providers: [provideHttpClient(withInterceptors([errorAndRetryInterceptor]))] });
The nonexistent domain triggers status 0 → the retry interceptor doesn't retry (only 5xx) → the error interceptor normalizes the message → the component shows "Network offline" with a retry button.
get_best_practicesCap at 2-3 retries with exponential backoff (250ms, 500ms, 1000ms). Retry only on 5xx and network errors (status 0). NEVER retry 4xx (client errors don’t fix themselves) or non-idempotent mutations (POST/DELETE — they could duplicate). Above all, ensure the UI tells the user something is happening — silent retries that take 5 seconds feel like a broken app, even if the data eventually loads.Lesson 6.5 picks up caching strategies with httpResource.
Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.
1 comment



Pingback: Angular httpResource vs resource: Which to Use [2026]