Every app breaks sometimes. Good error handling means users see a helpful message, and you get the details to fix the bug.
What you will learn
- The global
ErrorHandlerand how to replace it - Handling
HttpErrorResponsein one place with an interceptor - Showing friendly messages and logging to your server
Two kinds of errors
- Unexpected bugs in your code (undefined is not a function). Use a global handler to log them.
- Expected failures such as a 404, a lost connection or a validation error from the server. Handle them where they happen, and show a message.
The global ErrorHandler
Angular sends every uncaught error to a service called ErrorHandler. By default it prints to the console. Replace it to send errors to your own logging:
// src/app/errors/error-log.ts
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class ErrorLog {
readonly messages = signal<string[]>([]);
add(message: string) {
this.messages.update((list) => [...list, message]);
}
}
// src/app/errors/global-error-handler.ts
import { ErrorHandler, Injectable, inject } from '@angular/core';
import { ErrorLog } from './error-log';
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
private readonly log = inject(ErrorLog);
handleError(error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
this.log.add(message);
console.error('Unhandled error:', error); // keep the console output for developers
// in a real app: fetch('/api/log', { method: 'POST', body: JSON.stringify({ message }) })
}
}
Register it in app.config.ts:
// File: app.config.ts
providers: [
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
provideBrowserGlobalErrorListeners(), // also catches errors outside Angular, like window.onerror
]
Logic: you replace one provider, and from then on every unhandled error flows through your code. The provideBrowserGlobalErrorListeners() function (already in new projects) forwards window errors and unhandled promise rejections to the same handler.
HTTP errors in one place
A failed request gives an HttpErrorResponse. Its status tells you what happened: 0 means no network, 401 not signed in, 404 not found, 500 server bug. Translate them into messages in one interceptor:
// src/app/errors/http-error.interceptor.ts
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { ErrorLog } from './error-log';
export function friendlyMessage(error: HttpErrorResponse): string {
switch (error.status) {
case 0:
return 'No connection. Check your internet and try again.';
case 401:
return 'Please sign in again.';
case 404:
return 'We could not find that.';
default:
return error.status >= 500 ? 'The server had a problem. Please try later.' : 'Something went wrong.';
}
}
export const httpErrorInterceptor: HttpInterceptorFn = (req, next) => {
const log = inject(ErrorLog);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
log.add(friendlyMessage(error));
return throwError(() => error); // rethrow so the caller can still react
}),
);
};
// File: register it
provideHttpClient(withInterceptors([httpErrorInterceptor]))
Notice: the interceptor rethrows. The component can still show a retry button or stop its spinner, while the interceptor shows the message centrally.
Showing the messages
// src/app/errors/error-banner.ts
import { Component, inject } from '@angular/core';
import { ErrorLog } from './error-log';
@Component({
selector: 'app-error-banner',
template: `
@for (m of log.messages(); track $index) {
<p role="alert" class="error">{{ m }}</p>
}
`,
})
export class ErrorBanner {
protected readonly log = inject(ErrorLog);
}
role="alert" makes screen readers announce the message.
Test it
// src/app/errors/errors.spec.ts
import { ErrorHandler } from '@angular/core';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { ErrorLog } from './error-log';
import { GlobalErrorHandler } from './global-error-handler';
import { httpErrorInterceptor } from './http-error.interceptor';
describe('error handling', () => {
it('global handler records unexpected errors', () => {
TestBed.configureTestingModule({ providers: [{ provide: ErrorHandler, useClass: GlobalErrorHandler }] });
vi.spyOn(console, 'error').mockImplementation(() => {});
TestBed.inject(ErrorHandler).handleError(new Error('boom'));
expect(TestBed.inject(ErrorLog).messages()).toEqual(['boom']);
});
it('interceptor turns a 404 into a friendly message and rethrows', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(withInterceptors([httpErrorInterceptor])), provideHttpClientTesting()],
});
let failed = false;
TestBed.inject(HttpClient).get('/api/x').subscribe({ error: () => (failed = true) });
TestBed.inject(HttpTestingController).expectOne('/api/x').flush('nope', { status: 404, statusText: 'Not Found' });
expect(failed).toBe(true);
expect(TestBed.inject(ErrorLog).messages()).toEqual(['We could not find that.']);
});
});
Common mistakes
- Swallowing errors with an empty
catch. You lose the bug report. - Showing raw server messages or stack traces to users.
- Logging personal data (passwords, tokens) in error reports.
- Handling the same error in the interceptor and showing a second message in the component.
Try it yourself
- Throw an error from a button click and see your handler log it.
- Add a 403 case with the message "You do not have permission".
- Send the message to your backend with
fetchand store it.
Quick check
- Does a
catchErrorhandled error reachErrorHandler? - What does HTTP status 0 mean?
- Why rethrow inside the interceptor?
Next, continue with Angular NgModules (Legacy), Module Routing, Lazy Modules and Migration to Standalone.