A good error strategy has layers: predictable errors are handled locally (a message next to the data), cross-cutting ones centrally (an interceptor), and anything that escapes reaches a global ErrorHandler, which logs it. New in v22.2: the @boundary block isolates the rendering errors of an area, so an error in one widget doesn’t break the whole page.
The layers
Where you handle each kind of error:
Predictable HTTP error (404, 422) → in the store/component: resource.error(), a message for the user
Cross-cutting HTTP error (401, 500) → interceptor (redirect to login, toast, retry)
Rendering error of an area → @boundary (v22.2, developer preview)
Anything else not caught → global ErrorHandler (log + telemetry)
Form validation → Signal Forms (errors(), not exceptions)@boundaryv22 developer preview
<!-- v22.2 (developer preview): isolates rendering errors of an area -->
@boundary {
<app-price-widget [price]="price()" />
<app-recommendations />
} @error (let error = $error; let reset = $reset) {
<app-error-card [message]="error.message" (retry)="reset()" />
}
<!-- Several @error blocks with `when` conditions — the first match wins -->
@boundary {
<app-report />
} @error (let e = $error; when e instanceof PermissionError) {
<p>You don’t have access to this report.</p>
} @error (let e = $error; let retry = $reset) {
<p>Something went wrong. <button (click)="retry()">Retry</button></p>
}@boundary and the ErrorBoundary APIs (ErrorDetails, onError on createComponent) are marked @developerPreview 22.2. You can use them, but the syntax may still change. A caught error is still reported to ErrorHandler.
Global ErrorHandler
import { ErrorHandler, inject } from '@angular/core';
export class AppErrorHandler implements ErrorHandler {
private readonly log = inject(ErrorLog);
private readonly telemetry = inject(Telemetry);
handleError(error: unknown): void {
this.log.add(error);
this.telemetry.captureException(error); // Sentry, App Insights, your own backend
console.error(error);
}
}
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(), // window.onerror + unhandledrejection → ErrorHandler
{ provide: ErrorHandler, useClass: AppErrorHandler },
],
};- No error yet. Press a button — the error reaches AppErrorHandler, registered in app.config.ts.
HTTP errors
// Interceptor for cross-cutting errors
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
const toasts = inject(ToastService);
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401) router.navigate(['/login']);
else if (err.status >= 500) toasts.show('The server has problems. Try again later.', 'bad');
return throwError(() => err); // also let the caller react
}),
);
};Dynamic components: onError
// For dynamically created components (v22.2): onError
const ref = viewContainer.createComponent(Widget, {
onError: (error, details) => {
console.warn('The widget', details.declarationType.name, 'failed', error);
this.vcr.clear(); // replace with a fallback
},
});Errors from resource and httpResource
A resource does not rethrow the error. It keeps it in its state: status() becomes 'error', and error() tells you what happened. Careful: reading value() in the error state throws. That is why the template checks hasValue() or error() first.
readonly user = httpResource<User>(() => `/api/users/${this.id()}`);
// error first, then value: value() throws in the error state
@if (user.error(); as e) {
<p role="alert">We could not load the profile.</p>
<button (click)="user.reload()">Try again</button>
} @else if (user.hasValue()) {
<h2>{{ user.value().name }}</h2>
} @else {
<app-skeleton />
}Errors that do not go through Angular
Some errors do not start in code Angular runs: a setTimeout inside a library, a Promise rejected without a catch. provideBrowserGlobalErrorListeners() listens to the window’s error and unhandledrejection events and sends them to the ErrorHandler as well, so you have one place for all of them. New projects already get it in app.config.ts.
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(), // window error + unhandledrejection → ErrorHandler
{ provide: ErrorHandler, useClass: AppErrorHandler },
],
};What you send to the server
An ErrorHandler that only writes to the console does not help in production, because nobody sees the user’s console. Send errors to a logging service, but carefully:
- Deduplicate. The same error inside a loop can create thousands of reports in a minute. Remember the messages you sent recently.
- No personal data. Do not send tokens, form contents or URLs that contain emails. Send the message, the stack and the route.
- Do not block the app. Send with
navigator.sendBeaconor with a request that cannot throw in turn.
Retries with growing delays are covered in Advanced RxJS: higher-order operators; a page that must answer with a 404 during server rendering, in Server-compatible code.
Common pitfalls
- Swallowed errors: a
catchError(() => of([]))without logging makes bugs invisible. - A toast for every error: the user gets 5 messages for one problem. Centralise and de-duplicate.
- An ErrorHandler that throws: if your handler has a bug, you lose all errors. Keep it simple and safe.
- @boundary doesn’t catch errors from event handlers or from async code — only from rendering. Those reach
ErrorHandler.
Cheat sheet
// rendering: isolate an area (v22.2, developer preview)
@boundary { <app-widget /> } @error (let e = $error) { <p>Widget unavailable</p> }
// local: next to the data that failed
catchError((e) => { this.error.set(e); return of(null); })
// resources: the error is state, not an exception
res.error() res.hasValue() res.status() === 'error' res.reload()
// global: everything that slips through ends up here
provideBrowserGlobalErrorListeners()
{ provide: ErrorHandler, useClass: AppErrorHandler }