skip to content

After an Angular app goes zoneless, errors from setTimeout callbacks and un-awaited promises stop reaching the ErrorHandler; why, and what fixes it?

level: seniorimportance: should knowfreq 35%

answer

  1. who used to catch async errors
  2. NgZone's onError stream
  3. window error and unhandledrejection
  4. a one-line provider since v20
  5. check older app configs

basics

~10 s

With zone.js, errors in async tasks inside the Angular zone were forwarded to ErrorHandler through the zone. Zoneless removes that path. provideBrowserGlobalErrorListeners() restores it by forwarding window error and unhandledrejection events to ErrorHandler.

solid answer

~40 s

In a zone-based app, zone.js wraps timers, promises and event callbacks running in the Angular zone; when one throws, `NgZone.onError` emits and bootstrap has subscribed the `ErrorHandler` to that stream. That is why errors from a `setTimeout` or an un-awaited promise used to appear in the handler even though Angular never called that code. A zoneless app — the default since v21 — has no zone to intercept anything, so those errors go straight to the browser and only the console sees them. The fix is `provideBrowserGlobalErrorListeners()` from `@angular/core`, available since v20 and generated by the CLI in new apps: it adds `error` and `unhandledrejection` listeners on `window`, forwards the errors to `ErrorHandler` and calls `preventDefault()`. Errors Angular catches itself — listeners, hooks, bindings, the `async` pipe — reach the handler either way.

code

ts · 14 lines
ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-sync-button',
  template: `<button (click)="sync()">Sync</button>`,
})
export class SyncButton {
  sync(): void {
    setTimeout(() => {
      // Zoneless: reaches ErrorHandler only through provideBrowserGlobalErrorListeners()
      throw new Error('Background sync failed');
    });
  }
}

go deeper

for a junior

Recall that zoneless apps need provideBrowserGlobalErrorListeners() for window errors to reach the ErrorHandler.

for a middle

Explain the two paths: Angular catching code it calls, and zone.js forwarding async task errors through NgZone.onError.

for a senior

Audit a migrated app: add the provider, prove timer and promise errors arrive, and avoid double reporting with monitoring SDKs.

for a principal

Treat the zoneless switch as a monitoring change too, and require evidence that error coverage survived it before rollout.

## Two ways errors reached the handler Angular reports errors to `ErrorHandler` by two quite different mechanisms: 1. **Direct catching.** When Angular itself calls your code — a template event listener, a lifecycle hook, a template binding during change detection, the `async` pipe's subscription — it wraps the call and forwards any error. This works with or without zone.js. 2. **Zone interception.** In a zone-based app, zone.js patches browser async APIs. Every task scheduled inside the Angular zone — a `setTimeout` callback, a promise continuation, an `addEventListener` callback — runs through the zone. When one throws, the zone's error hook fires, `NgZone.onError` emits, and during bootstrap Angular subscribed the `ErrorHandler` to that stream. The second path is why developers came to believe that the handler "catches everything": for years, almost any async error in an Angular app flowed through zone.js. ## What zoneless changes Since v21 new applications are **zoneless** by default: zone.js is not loaded and `NgZone` is a no-op whose `onError` never emits. The first path is untouched, but the second disappears. Now: - an error thrown in your own `setTimeout` or `setInterval` callback, - a rejected promise nobody awaits or catches, - an RxJS `subscribe()` without an error callback (RxJS rethrows it asynchronously), - an error in a callback from a third-party library, all go to the browser's global error mechanism, where the default behaviour is a console message. A custom handler that sends reports to monitoring silently loses them. ## The fix: `provideBrowserGlobalErrorListeners()` Added in v20, this provider registers an environment initializer that, in the browser: - adds a `window` listener for **`error`** events and forwards `event.error` to the application's error handler — or, when the event has no error object, wraps its message in a new `Error` with the event as `cause`; - adds a `window` listener for **`unhandledrejection`** and forwards `event.reason`; - calls `preventDefault()` on both events, so the browser's own logging is suppressed and your handler's output replaces it; - removes both listeners when the application is destroyed; - does nothing during server-side rendering. The CLI generates new applications with this provider in `app.config.ts` (or the root module), so the gap appears mostly in projects created earlier and migrated to zoneless by hand. | Error source | Zone-based, no listeners | Zoneless, no listeners | Zoneless + `provideBrowserGlobalErrorListeners()` | |---|---|---|---| | Template `(click)` listener throws | Reported | Reported | Reported | | Lifecycle hook throws | Reported | Reported | Reported | | `setTimeout` callback in your code throws | Reported if inside the Angular zone | Console only | Reported | | Un-awaited promise rejects | Reported if inside the Angular zone | Console only | Reported | ## Checking an existing app 1. Search the app config for `provideBrowserGlobalErrorListeners()`; add it if missing. 2. Throw deliberately from a `setTimeout` in a dev build and confirm your handler receives it. 3. If the app already installs its own window listeners, for example a monitoring SDK, decide which one owns global errors so each error is reported once. The Angular guidance says you can remove the provider when you install custom listeners. 4. Make sure the handler never produces an unhandled rejection itself; with the listener installed, that would feed straight back into the handler. ## What zone-based apps already missed Zone interception only covered tasks running **inside** the Angular zone. Code started with `NgZone.runOutsideAngular()` — often used for scroll handlers, animations or third-party widgets to avoid extra change detection — and scripts loaded outside Angular never went through `NgZone.onError`, so their errors never reached the handler either. The global listeners do not care which zone an error came from: anything that reaches `window` as an uncaught error or unhandled rejection is forwarded. That is one reason the Angular team recommends handling global errors in most applications, with the built-in listeners or custom ones. ## Why this is not a reason to avoid zoneless The listeners cover the same async sources more predictably than zone.js did, and they also catch errors from code running outside the Angular zone, which zone-based apps missed. The migration simply turns an implicit safety net into an explicit provider.

  • Does provideBrowserGlobalErrorListeners() do anything during server-side rendering?
    No. Its setup returns early in server mode and when there is no `window`. On the server, Angular's SSR support adds its own process-level listeners that log errors instead of letting the process crash.
  • Why might errors be reported twice after adding the provider?
    Another component, often a monitoring SDK, may also listen for window `error` and `unhandledrejection` events and report on its own, while Angular's listener forwards the same errors to your handler. Pick one owner for global errors.

saying these in an interview costs you the question

  • ErrorHandler catches async errors by itself in zoneless apps
  • Zoneless apps stop reporting errors from template listeners
  • provideBrowserGlobalErrorListeners is only needed with zone.js
  • The global listeners also run during server-side rendering
  • Dropping zone.js means the ErrorHandler is never called