Skip to content

08 · Error Handling & Observability

Every production app throws errors — bad data from an API, a browser extension messing with the DOM, a chunk that no longer exists after a deploy. What matters is that (1) you find out about them, with enough context to fix them, and (2) one failure doesn't take down the whole page. This lesson wires up both, with every behaviour checked in a production build running in Chromium.

One place for errors: ErrorHandler

Angular routes errors it catches to the ErrorHandler service. Replace it with your own to send errors somewhere useful:

src/app/obs/app-error-handler.ts
import { ErrorDetails, ErrorHandler, Service } from '@angular/core';

export interface ReportedError {
  kind: 'error' | 'view';
  message: string;
  component?: string;
}

/** Collects errors; in a real app, send them to your logging/monitoring service. */
@Service({ autoProvided: false })
export class AppErrorHandler implements ErrorHandler {
  readonly reported: ReportedError[] = [];

  handleError(error: unknown): void {
    this.reported.push({ kind: 'error', message: messageOf(error) });
    console.error('[reported]', messageOf(error));
  }

  onViewError(error: Error, details: ErrorDetails): void {
    this.reported.push({ kind: 'view', message: error.message, component: details.declarationType.name });
    console.error('[reported view error]', details.declarationType.name, error.message);
  }
}

function messageOf(error: unknown): string {
  if (error instanceof Error) return error.message;
  if (typeof error === 'object' && error && 'reason' in error) return String((error as { reason: unknown }).reason);
  return String(error);
}
src/app/app.config.ts
import { ApplicationConfig, ErrorHandler, provideBrowserGlobalErrorListeners } from '@angular/core';
import { AppErrorHandler } from './obs/app-error-handler';
import { provideHttpClient } from '@angular/common/http';
import { NavigationError, provideRouter, withComponentInputBinding, withNavigationErrorHandler } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(
      routes,
      withComponentInputBinding(),
      withNavigationErrorHandler((e: NavigationError) => {
        console.error('[navigation error]', e.url, String(e.error).slice(0, 80));
        // A missing chunk usually means a new version was deployed: reload to get it.
        if (String(e.error).includes('Failed to fetch dynamically imported module')) {
          (window as { __wouldReload?: boolean }).__wouldReload = true; // location.reload() in a real app
        }
      }),
    ),
    AppErrorHandler,
    { provide: ErrorHandler, useExisting: AppErrorHandler },
    provideHttpClient(),
  ],
};

provideBrowserGlobalErrorListeners() — generated by ng new — is what makes errors outside Angular's own call stacks (a setTimeout, an unhandled promise rejection) reach the handler too.

What reaches the handler

We built a page with buttons that fail in different ways and recorded what the handler reported:

throw in a (click) handler      [reported] boom in handler
Promise.reject(...) unhandled   [reported] boom in promise
throw inside setTimeout         [reported] boom in timeout
throw while rendering (@if)     [reported] boom while rendering
afterwards: clicking "Increment" still worked — "clicks still work: 1"
uncaught errors left for the browser: none

All four arrived, and the app kept running. One detail: the rendering error was reported again on a later change-detection pass, because the broken binding is re-evaluated every time that view refreshes. Deduplicate in your handler (by message and stack) before sending to a monitoring service, or you'll pay for thousands of copies of one bug.

Containing failures: @boundary

A rendering error in one widget shouldn't blank the whole page. Angular 22's template syntax includes a @boundary block with a connected @error block, which receives the error ($error) and a function to try again ($reset):

<section>
  @boundary {
    <p class="widget">{{ widget() }}</p>
  } @error (let e = $error; let reset = $reset) {
    <p role="alert">This widget failed: {{ e.message }}</p>
    <button (click)="widgetBroken.set(false); reset()">Retry</button>
  }
</section>

Observed in Chromium:

1 initial          Widget OK
2 widget broken    This widget failed: widget data was malformed | Retry
                   handler: [reported view error] i widget data was malformed
3 rest of page     clicks still work: 1
4 after Retry      Widget OK

Errors caught by a boundary are passed to ErrorHandler.onViewError(error, details) (if you implement it) with details.declarationType — the component class — instead of handleError. Note the component name in our log: i. Production builds minify class names, so don't rely on constructor.name for grouping errors in production; send source-mapped stack traces instead (below). The @error block also accepts a when condition to handle only certain errors.

@boundary is recent; it isn't covered by older tutorials, and its API may still evolve — check the angular.dev error-handling guide for your version before relying on details.

If a lazy route's chunk can't be loaded — typically because a new deploy removed the old chunk while a user still had the old index.html open (lesson 07) — the navigation fails. We simulated exactly that by making the server return 404 for the search chunk and clicking the link:

Failed to load resource: the server responded with a status of 404 (Not Found)
[navigation error] /search TypeError: Failed to fetch dynamically imported module: http://…/chunk-CJeHOBIV.js
[reported] Failed to fetch dynamically imported module: http://…/chunk-CJeHOBIV.js

The URL stayed on the current page, withNavigationErrorHandler saw the error, and our handler set the "reload" flag. In production, reloading (once — guard against loops) is the standard fix: the new index.html references the new chunk names. The handler may also return a RedirectCommand to send the user to an error page.

Observability: beyond console.error

In production, nobody reads the console. Send errors to a monitoring service — hosted products such as Sentry, Datadog, New Relic or Bugsnag, or your own endpoint — from handleError/onViewError. What to include:

  • the message and stack trace, plus the release (BUILD_VERSION from lesson 01) so you can upload matching source maps — build with "sourceMap": { "scripts": true, "hidden": true } in the production configuration to generate maps without linking them publicly (in our build: 5 .map files, and no sourceMappingURL comment in main-*.js), and upload them to your monitoring tool;
  • the current route (inject(Router).url) and non-sensitive user context;
  • the HTTP status for HttpErrorResponse / ApiError (Level 2, lesson 08) — and consider not reporting expected 4xx responses at all.

Never include passwords, tokens or personal data in reports.

For performance in the field, measure Core Web Vitals (LCP, INP, CLS) from real users with the web-vitals library or your monitoring vendor's browser SDK, tagged with the route. And for server-rendered apps, log server errors and render times from server.ts like any Node service.

How It Actually Works

Angular catches errors at its own entry points: event listeners it installed, change detection, lifecycle hooks, effects, router navigation, and resource loaders. Instead of letting them propagate to the browser, it calls ErrorHandler.handleError. Errors thrown in code Angular didn't call — your own setTimeout callback, a promise nobody awaited — would normally surface as window error / unhandledrejection events; provideBrowserGlobalErrorListeners() adds listeners for those events and forwards them to the same handler. In zoneless apps this listener is what replaces the error-catching that Zone.js used to provide.

A @boundary block compiles to a container with two templates: the primary content and the error view. When creating or refreshing the primary view throws, Angular records the error on the boundary, calls onViewError (or handleError), removes the broken view and renders the @error template instead, with $error and $reset in its context. $reset clears the error and re-creates the primary view on the next pass — which works if you've fixed the state it depends on, as our Retry button did.

Common mistakes

  • No custom ErrorHandler, so production errors vanish into users' consoles.
  • Reporting every repeat of the same rendering error.
  • Relying on class names in minified builds.
  • Swallowing errors (catch {}) so neither the user nor the handler learns anything.
  • Reload loops when handling chunk-load errors — reload at most once per version.
  • Leaking sensitive data in error reports.

Exercise

  1. Implement an ErrorHandler that batches errors and POSTs them to /api/client-errors at most once every 10 seconds, deduplicating by message + first stack line.
  2. Wrap a risky dashboard widget in @boundary with a friendly @error block and a Retry button; trigger an error and confirm the rest of the page still works.
  3. Handle chunk-load navigation errors by reloading once, storing a flag in sessionStorage to prevent loops.
  4. Build with hidden source maps and confirm the .map files exist but the bundles don't reference them (sourceMappingURL absent).