Angular applications handle expected request failures near the feature and unexpected framework errors at a final reporting boundary. Preserve the original cause, avoid duplicate notifications, and model loading, empty, stale, and retry states explicitly.
Handle an expected failure at the narrowest boundary that can recover: a form displays validation feedback, a data service maps an API failure to domain state, and a page offers retry or alternate navigation. Unexpected programming errors belong in centralized reporting, not a silent fallback.
Model loading, empty, success, stale, and error states separately. An empty successful response is not an error, and a failed refresh should not necessarily erase previously usable data.
Use RxJS catchError where the application can translate an HttpErrorResponse into a domain result, truthful fallback, or rethrown failure. Services can normalize transport details, while components still own the user-facing retry and display state.
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
export interface User { id: number; name: string; email: string; }
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
getUser(id: number) {
return this.http.get<User>(`/api/users/${id}`).pipe(
catchError((error: HttpErrorResponse) => {
let message = 'An unexpected error occurred.';
if (error.status === 0) {
message = 'Network error - check your connection.';
} else if (error.status === 401) {
message = 'Unauthorized - please log in.';
} else if (error.status === 403) {
message = 'Forbidden - you do not have permission.';
} else if (error.status === 404) {
message = 'User not found.';
} else if (error.status >= 500) {
message = 'Server error - please try again later.';
}
console.error('HTTP Error:', error);
return throwError(() => new Error(message));
})
);
}
}
import { Component, inject, signal } from '@angular/core';
import { UserService, User } from './user.service';
@Component({
selector: 'app-user',
standalone: true,
template: `
@if (loading()) {
<p>Loading...</p>
} @else if (error()) {
<div class="tl-alert alert-danger">{{ error() }}</div>
<button (click)="load()">Retry</button>
} @else if (user()) {
<h2>{{ user()!.name }}</h2>
<p>{{ user()!.email }}</p>
}
`
})
export class UserComponent {
private userService = inject(UserService);
user = signal<User | null>(null);
loading = signal(false);
error = signal<string | null>(null);
ngOnInit() { this.load(); }
load() {
this.loading.set(true);
this.error.set(null);
this.userService.getUser(1).subscribe({
next: (u) => { this.user.set(u); this.loading.set(false); },
error: (e) => { this.error.set(e.message); this.loading.set(false); }
});
}
}
An interceptor can translate transport failures that have one application-wide policy, such as expiring an authenticated session or attaching a correlation identifier. Keep feature-specific messages and retry decisions near the feature that understands the operation.
Do not show a toast in the interceptor and another message in the component for the same failure. A 401 flow must exclude the sign-in or refresh request, coordinate concurrent failures, preserve the attempted URL when appropriate, and stop cleanly when refresh fails.
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
// Redirect to login on unauthorized
router.navigate(['/login']);
}
if (error.status === 403) {
router.navigate(['/forbidden']);
}
if (error.status >= 500) {
console.error('Server error:', error.message);
// Could show a toast notification here
}
return throwError(() => error);
})
);
};
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { errorInterceptor } from './error.interceptor';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(
withInterceptors([errorInterceptor])
)
]
};
A custom ErrorHandler can normalize and send unexpected errors to the project monitoring boundary, but it should avoid throwing another error or exposing stack traces and private data to users.
Capture release version, route template, browser context, and a correlation identifier where policy allows. Redact tokens, request bodies, personal data, and arbitrary user-entered values before transmission.
Global reporting is the last line of visibility, not a recovery strategy. Keep local error states and retry decisions near the operation that understands them.
import { ErrorHandler, Injectable, inject } from '@angular/core';
abstract class ErrorReporter {
abstract capture(event: {
name: string;
message: string;
stack?: string;
}): void;
}
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
private readonly reporter = inject(ErrorReporter);
handleError(error: unknown): void {
const normalized = error instanceof Error
? error
: new Error('Unknown application error', { cause: error });
try {
this.reporter.capture({
name: normalized.name,
message: normalized.message,
stack: normalized.stack
});
} catch (reportingFailure) {
console.error('Error reporting failed', reportingFailure);
}
console.error(normalized);
}
}
import { ApplicationConfig, ErrorHandler } from '@angular/core';
import { provideRouter } from '@angular/router';
import { GlobalErrorHandler } from './global-error-handler';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
// Override the default ErrorHandler
{ provide: ErrorHandler, useClass: GlobalErrorHandler }
]
};
Use built-in control flow with an explicit view model to render loading, stale, empty, failure, and success states. Keep the previous value visible during a failed refresh when that is safer than replacing usable data with a blank screen.
The @defer @error block handles failure to load that deferred dependency. It does not catch arbitrary exceptions thrown later by the rendered component, and a route-level not-found page does not replace an API-specific error state.
| Approach | Best for |
|---|---|
| Signal error state + @if | Component-level HTTP errors |
| HTTP Interceptor | Global auth errors (401, 403) |
| ErrorHandler | Unhandled JS exceptions, crash reporting |
| @defer @error | Lazy-loaded component failures |
| Route-level error pages | 404, 500 pages |
catchError replaces the failed stream with whatever Observable it returns. Returning of([]) turns the failure into a successful empty result, while throwError preserves an error path for callers.
The interceptor displays a notification and the component or service also handles the rethrown error. Decide which layer owns user-facing feedback.
Some exceptions are recoverable or confined to one optional feature. Redirecting the whole application can destroy unsaved work and create a navigation loop if the error page triggers the same fault.
Explore 500+ free tutorials across 20+ languages and frameworks.