Ch. 4 · Angular

Angular Global Error Handling

What actually reaches Angular's ErrorHandler, how to write one that never throws or loops, and where HTTP and chunk-load errors belong.

~7 min readadvancedupdated Oct 19, 2026

Every Angular app has an ErrorHandler whether you write one or not. The default implementation is three lines long: it calls console.error('ERROR', error) and returns. That’s fine on your laptop and useless in production, where nobody is looking at the console. So teams replace it with a handler that ships errors to Sentry, Datadog or their own endpoint, and that’s usually where the interview question starts: “How would you set up global error handling in Angular?”

A good answer has three parts: which errors actually arrive at the handler, how to write a handler that can’t make an outage worse, and which errors should never get that far.

Which errors reach ErrorHandler

The rule Angular follows is that errors should surface where the operation started. When your code calls a service method that throws, Angular never sees it; you’re expected to catch it. The framework steps in only when it is the caller and there’s nowhere for you to put a try block:

  • component constructors and lifecycle hooks (ngOnInit, ngOnChanges, and so on)
  • template expressions and template event listeners, such as (click)="save()"
  • AsyncPipe and PendingTasks.run, which have an explicit contract to wait for a result
  • effects and other code scheduled by the framework

Some APIs report failures in their return value instead. A resource() that fails moves to status 'error' and exposes the error on its error() signal, so nothing reaches ErrorHandler at all. Likewise, an HttpClient observable whose error you handle with catchError or an error: callback is finished as far as Angular is concerned.

The interesting cases are the ones nobody caught. An RxJS subscription with no error callback reports the error by rethrowing it asynchronously. A promise that rejects with no .catch() raises unhandledrejection on window. With zone.js loaded, both happen inside Angular’s zone, so the zone catches them and forwards them to ErrorHandler. Without zone.js, nothing catches them by default. That’s why Angular 20 added provideBrowserGlobalErrorListeners(), which listens for the window’s error and unhandledrejection events and forwards both to your handler. New CLI projects include it in app.config.ts. If you migrate an older app to zoneless and delete that line, your error tracker goes quiet and you’ll think things improved.

Note

There’s one window where even a correct setup misses errors: while Angular is still creating the root component, it can’t look up your handler yet. Errors thrown there behave like ordinary uncaught exceptions. This mostly bites Angular Elements, where a custom element upgrades during startup.

Writing a handler that can’t make things worse

The global handler runs when the app is already in a bad state, so it has to be the most defensive code you own. Four rules cover most of the ways these handlers go wrong.

Never throw from handleError. If the reporting code throws, the error goes back into the same machinery, and depending on where it came from you can get an infinite loop or a frozen tab. Wrap everything after the initial console.error in try/catch, and guard against re-entry.

Resolve dependencies lazily. ErrorHandler is created very early. If its constructor injects HttpClient, and an interceptor injects something that depends on ErrorHandler, you get a circular dependency error at startup (NG0200). Inject the Injector and call injector.get(...) inside handleError, only when you need the service.

Dedupe. A template error inside an @for over 200 rows can fire 200 times in a single change detection pass. Sending 200 identical events burns your error-tracking quota and buries the one report that matters. Fingerprint the error and drop repeats for a few seconds.

Unwrap before you classify. With zone.js, an unhandled rejection arrives wrapped in an Error whose message begins Uncaught (in promise):, with the original value on a rejection property. An HttpErrorResponse that nobody handled isn’t an Error instance at all. Normalise both before deciding what to do.

A worked example: normalise, dedupe, report

Start with the logic that doesn’t depend on Angular, so you can test it in plain Node:

// report.ts
export interface ErrorReport {
  kind: 'http' | 'chunk' | 'runtime';
  message: string;
  status?: number;
  fingerprint: string;
}

const CHUNK_PATTERNS = [
  /Failed to fetch dynamically imported module/i, // Chromium
  /error loading dynamically imported module/i,   // Firefox
  /Importing a module script failed/i,            // Safari
];

export function toReport(raw: unknown): ErrorReport {
  const error = (raw as { rejection?: unknown })?.rejection ?? raw;

  if (isHttpError(error)) {
    const message = `HTTP ${error.status} ${error.url ?? ''}`.trim();
    return { kind: 'http', status: error.status, message, fingerprint: message };
  }

  const message = error instanceof Error ? error.message : String(error);
  const kind = CHUNK_PATTERNS.some((p) => p.test(message)) ? 'chunk' : 'runtime';
  const firstFrame = error instanceof Error ? (error.stack ?? '').split('\n')[1]?.trim() ?? '' : '';
  return { kind, message, fingerprint: `${message}|${firstFrame}` };
}

function isHttpError(e: unknown): e is { name: string; status: number; url: string | null } {
  return typeof e === 'object' && e !== null && (e as { name?: string }).name === 'HttpErrorResponse';
}

export class Deduper {
  private seen = new Map<string, number>();
  private readonly windowMs: number;
  private readonly now: () => number;

  constructor(windowMs = 10_000, now = () => Date.now()) {
    this.windowMs = windowMs;
    this.now = now;
  }

  shouldSend(fingerprint: string): boolean {
    const last = this.seen.get(fingerprint);
    const t = this.now();
    if (last !== undefined && t - last < this.windowMs) return false;
    this.seen.set(fingerprint, t);
    return true;
  }
}
TypeScript

Feeding it a repeated TypeError, an unhandled 503, a wrapped chunk failure and a thrown string, then advancing a fake clock by 11 seconds, prints:

send runtime - Cannot read properties of undefined (reading 'total')
skip runtime - Cannot read properties of undefined (reading 'total')
send http - HTTP 503 /api/orders
send chunk - Failed to fetch dynamically imported module: https://shop.test/chunk-ABC123.js
send runtime - plain string thrown
send after 11s
Text

The second TypeError is dropped, the chunk error is recognised even though zone.js wrapped it, and the string still produces a usable report. Now the Angular part is thin:

// global-error-handler.ts
import { ErrorHandler, Injectable, Injector, inject, signal } from '@angular/core';
import { toReport, Deduper } from './report';

@Injectable({ providedIn: 'root' })
export class UpdateBanner {
  readonly visible = signal(false);
}

@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
  private readonly injector = inject(Injector);
  private readonly dedupe = new Deduper();
  private reporting = false;

  handleError(raw: unknown): void {
    console.error(raw);
    if (this.reporting) return;
    this.reporting = true;
    try {
      const report = toReport(raw);
      if (report.kind === 'chunk') {
        this.injector.get(UpdateBanner).visible.set(true);
      }
      if (this.dedupe.shouldSend(report.fingerprint)) {
        navigator.sendBeacon?.('/api/client-errors', JSON.stringify({ ...report, path: location.pathname }));
      }
    } catch {
      // swallow: the reporter must not become a second source of errors
    } finally {
      this.reporting = false;
    }
  }
}
TypeScript
// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    provideBrowserGlobalErrorListeners(),
    { provide: ErrorHandler, useClass: GlobalErrorHandler },
  ],
};
TypeScript

A few decisions worth explaining in an interview. sendBeacon is used because it survives the page unloading, which matters when the next thing the user does is close the broken tab. The handler keeps the console.error call so local debugging still works. The chunk case sets a signal rather than calling location.reload() directly: a chunk 404 usually means a new deploy removed the old files, and a banner saying “A new version is available, reload” is kinder than throwing away a half-filled form. Using a signal also means the banner renders whether or not zone.js is present. With zone.js, handleError can run outside the Angular zone, and a plain property assignment there won’t trigger a render.

HTTP errors don’t belong here

It’s tempting to route every failed request to the global handler. Don’t. A 404 for a missing product, a 409 on a stale edit and a 422 on invalid input are expected outcomes with specific UI: a not-found page, a “someone else changed this” dialog, field errors. Handle those where the request is made. Cross-cutting HTTP policy (attach the token, redirect to login on 401, retry idempotent GETs on 503) fits an interceptor. What reaches ErrorHandler should be the leftovers: requests nobody handled, which is itself a bug worth reporting.

Testing the handler

Because the logic lives in report.ts, most tests need no TestBed at all. For the Angular wiring, remember that TestBed rethrows errors the framework catches, so a component test fails loudly instead of logging into the void. If you specifically want to check that the app keeps running after an error, configure TestBed.configureTestingModule({ rethrowApplicationErrors: false }) and assert that your handler was called.

Practice question: the silent error tracker

“After migrating to zoneless change detection, our error tracker reports 90% fewer frontend errors. Product is happy. Should you be?”

Answer. Probably not. The first suspect is coverage, not quality. Under zone.js, uncaught errors from setTimeout callbacks, unhandled promise rejections and subscriptions without error callbacks were caught by the zone and forwarded to ErrorHandler. Without zone.js, those errors go straight to window and are only forwarded if provideBrowserGlobalErrorListeners() (or your own error and unhandledrejection listeners) is registered. I’d check app.config.ts for that provider, then verify by throwing deliberately inside a setTimeout in a staging build and confirming the event arrives. If the provider was there all along, I’d compare error fingerprints before and after: if specific noisy errors disappeared because the migration fixed them, the drop is real. The point is to explain the number before celebrating it.

More in Angular

read ✓Angular · hard

Angular Zoneless Change Detection

Without zone.js, Angular renders only when notified. See which updates notify, which silently don't, and how TestBed catches the gaps for you.

~6 min readread →
read ✓Angular · mid

Angular Defer Blocks and Lazy Rendering

How @defer splits a template region into its own chunk, which trigger to pick, and why SSR renders only the placeholder unless you opt in.

~7 min readread →
read ✓Angular · mid

Angular Host Directives and Composition

Use hostDirectives to compose disabled, pressable and tooltip behaviour into components, control which inputs leak out, and predict run order.

~6 min readread →
esc