Angular Error Boundaries — Global Error Handler and Component Error UI

Angular’s ErrorHandler is the last line of defence for uncaught errors — template errors, promise rejections, and zone.js-caught exceptions that escape normal error handling. Connecting it to a monitoring service (Application Insights, Sentry) means every production error is captured with full context: URL, user ID, component tree, and browser information. The toast notification system provides the user-facing feedback for recoverable errors.

Global Error Handler and Toast System

// ── Global error handler ───────────────────────────────────────────────────
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
  private router = inject(Router);
  private config = inject(APP_CONFIG);

  handleError(error: unknown): void {
    const err = error instanceof Error ? error : new Error(String(error));

    // Log to console in development
    if (!this.config.production) {
      console.error('[GlobalErrorHandler]', err);
    }

    // Send to monitoring service in production
    if (this.config.production) {
      this.sendToMonitoring(err);
    }

    // Handle chunk load failures (lazy-loaded modules failed — typically stale deploy)
    if (err.message.includes('ChunkLoadError') ||
        err.message.includes('Loading chunk')) {
      // Reload the page to get fresh JS bundles
      window.location.reload();
      return;
    }
  }

  private sendToMonitoring(error: Error): void {
    // Application Insights:
    // appInsights.trackException({ exception: error, properties: {
    //   url: this.router.url,
    //   appVersion: this.config.appVersion,
    // }});

    // Or Sentry:
    // Sentry.captureException(error, { extra: { url: this.router.url } });
  }
}

// ── Toast notification service ────────────────────────────────────────────
export type ToastType = 'success' | 'info' | 'warning' | 'error';

export interface Toast {
  id:       number;
  type:     ToastType;
  message:  string;
  duration: number;
}

@Injectable({ providedIn: 'root' })
export class ToastService {
  private nextId  = 0;
  readonly toasts = signal<Toast[]>([]);

  show(message: string, type: ToastType = 'info', duration = 4000): void {
    const toast: Toast = { id: this.nextId++, type, message, duration };
    this.toasts.update(list => [...list, toast]);
    setTimeout(() => this.dismiss(toast.id), duration);
  }

  success(message: string) { this.show(message, 'success'); }
  info   (message: string) { this.show(message, 'info'); }
  warn   (message: string) { this.show(message, 'warning', 6000); }
  error  (message: string) { this.show(message, 'error',   8000); }

  dismiss(id: number): void {
    this.toasts.update(list => list.filter(t => t.id !== id));
  }
}

// ── Toast container component (place in AppComponent template) ────────────
@Component({
  selector:   'app-toast-container',
  standalone:  true,
  template: `
    <div class="toast-container" aria-live="polite" aria-atomic="false">
      @for (toast of toastService.toasts(); track toast.id) {
        <div class="toast" [class]="'toast-' + toast.type"
             role="alert" (click)="toastService.dismiss(toast.id)">
          @switch (toast.type) {
            @case ('success') { ✅ }
            @case ('error')   { ❌ }
            @case ('warning') { ⚠️ }
            @default          { ℹ️ }
          }
          {{ toast.message }}
        </div>
      }
    </div>
  `,
  styles: [`
    .toast-container { position: fixed; bottom: 1.5rem; right: 1.5rem;
                        display: flex; flex-direction: column; gap: .5rem;
                        z-index: 10000; max-width: 380px; }
    .toast           { padding: .75rem 1rem; border-radius: 8px; cursor: pointer;
                        animation: slideIn 200ms ease; box-shadow: 0 2px 8px rgba(0,0,0,.2); }
    .toast-success   { background: #22c55e; color: white; }
    .toast-error     { background: #ef4444; color: white; }
    .toast-warning   { background: #f59e0b; color: white; }
    .toast-info      { background: #3b82f6; color: white; }
    @keyframes slideIn { from { transform: translateX(100%); opacity: 0; }
                          to   { transform: translateX(0);    opacity: 1; } }
  `],
})
export class ToastContainerComponent {
  protected toastService = inject(ToastService);
}

// ── Register GlobalErrorHandler in app.config.ts ──────────────────────────
{ provide: ErrorHandler, useClass: GlobalErrorHandler }
Note: The ChunkLoadError handler (auto-reload on lazy module load failure) addresses a common production issue: a user has the app open when a new deployment is released. The old JavaScript bundles are replaced with new ones on the CDN. When the user navigates to a lazy-loaded route, the browser tries to load the old chunk hash which no longer exists on the CDN — a 404, manifesting as a ChunkLoadError. Reloading the page fetches the new bundle manifest and resolves the issue automatically.
Tip: The toast service’s aria-live="polite" container ensures screen readers announce new toasts to visually impaired users. Without this, notifications are invisible to screen readers. polite means the announcement waits until the user is idle (not mid-sentence). aria-live="assertive" would interrupt immediately — appropriate only for critical errors like “Session expired.” Use polite for informational toasts and consider assertive for errors that require immediate user action.
Warning: The GlobalErrorHandler runs outside Angular’s dependency injection zone in some scenarios. Avoid injecting services that depend on the Angular zone or change detection in the constructor. Use inject() inside methods if needed, or use runOutsideAngular() for the monitoring SDK initialization. Also, the GlobalErrorHandler should never throw — if the error handler itself throws, Angular enters an infinite loop trying to handle the handler’s error.

Common Mistakes

Mistake 1 — No ChunkLoadError handling (users stuck with broken lazy routes after deploy)

❌ Wrong — no ChunkLoadError handling; users see a white screen or JS error after a deployment; must manually refresh.

✅ Correct — detect ChunkLoadError in GlobalErrorHandler; auto-reload to get fresh bundles.

Mistake 2 — Toast service without aria-live (screen readers cannot read notifications)

❌ Wrong — no aria-live on toast container; screen reader users never hear toast notifications.

✅ Correct — aria-live="polite" on container; screen readers announce all new toasts.

🧠 Test Yourself

A component has an unhandled JavaScript error in an event handler (not from HTTP). Does the GlobalErrorHandler catch it?