ngguide
← Blog

What provideBrowserGlobalErrorListeners actually does

Igor Katsuba

The provider landed in @angular/core 20.0.0, and the CLI has generated it into app.config.ts since the same release:

typescript
export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes)
  ]
};

First in the list, before routing. Nothing in the generated project explains it, and it is not the kind of line you go looking up.

The docs describe it in one sentence: it “forwards unhandled errors to the ErrorHandler”. True, and it leaves out the part you will actually notice.

The whole thing

It is one environment initializer. This is what shipped in 20.0.0, verbatim (packages/core/src/error_handler.ts):

typescript
const errorHandler = inject(INTERNAL_APPLICATION_ERROR_HANDLER);
const rejectionListener = (e: PromiseRejectionEvent) => {
  errorHandler(e.reason);
  e.preventDefault();
};
const errorListener = (e: ErrorEvent) => {
  errorHandler(e.error);
  e.preventDefault();
};

Two listeners. Six lines. Start with the one they share.

It calls preventDefault

On error and unhandledrejection, calling preventDefault() tells the browser the event has been dealt with. The browser then does not do its default thing, which is to print the error to the console.

So the provider does not add a reporting path next to the one you had. It moves it.

The default ErrorHandler does log, so a new app looks unchanged. The day you replace it is the day the console goes dark.

Since 20.1, it invents an Error when the browser gives it none

ErrorEvent carries an error property, except when it does not. A cross-origin script gives you the famous Script error. — a message, no error object, no stack. The 20.0.0 listener passed that straight through, so handleError got undefined and the report said nothing at all.

#62081, released in 20.1.0, gives it something to hold:

typescript
const errorListener = (e: ErrorEvent) => {
  if (e.error) {
    errorHandler(e.error);
  } else {
    errorHandler(
      new Error(
        ngDevMode
          ? `An ErrorEvent with no error occurred. See Error.cause for details: ${e.message}`
          : e.message,
        {cause: e},
      ),
    );
  }
  e.preventDefault();
};

The original event survives on cause, which is where to look when a stack trace turns out to be one frame long and points at Angular.

Since 20.1, a late error goes back to the browser

The same release changed where the error goes when the injector is already gone (#61886). Resolving ErrorHandler out of a destroyed injector throws, so now it does not try:

typescript
if (injector.destroyed && !userErrorHandler) {
  setTimeout(() => {
    throw e;
  });
} else {
  userErrorHandler ??= injector.get(ErrorHandler);
  userErrorHandler.handleError(e);
}

A throw out of a setTimeout is an uncaught error on the window — and this one nobody calls preventDefault on. So an error arriving after teardown is the one case that still prints the way it used to, which is a confusing thing to meet without knowing why.

The guard is !userErrorHandler, not destroyed alone: a handler resolved earlier is kept and still called.

It registers outside the zone, on purpose

typescript
if (typeof Zone !== 'undefined') {
  Zone.root.run(setupEventListeners);
} else {
  setupEventListeners();
}

A listener added inside the Angular zone schedules change detection every time it fires. For an error listener that is a round of rendering per error, on top of whatever the error already did.

This is a fix (#60944), landed three weeks after the provider itself and before it ever shipped — which is worth knowing, because it is exactly the mistake a hand-rolled version makes.

21.0 took it one step further (#63404): the call into your ErrorHandler is itself wrapped in zone.runOutsideAngular. Registration was already outside; now the handler body is too, so whatever your reporting code does with promises and timers cannot schedule a render either.

It is a no-op on the server

typescript
if (typeof ngServerMode !== 'undefined' && ngServerMode) {
  return;
}

SSR gets its own handling, and it is not symmetric: Angular adds uncaughtException and unhandledRejection to the process, and with Zone.js only the second one, because errors raised inside the application zone never reach the process.

Why this shows up now

Under Zone.js, a rejected promise created in your code was already a patched promise, and the zone routed it to ErrorHandler. That is the mechanism you lose when you go zoneless — and losing it is invisible until the reports stop.

Which is why the provider is not really about the errors it catches. It is about the ones you used to see for free.

  • A rejection from a third-party script.
  • A setTimeout callback that throws.
  • Anything in a web component that is not Angular’s.

None of that ever went through Angular in the first place. Now it does, and the way it gets there is one preventDefault away from the console you have been reading.

What to do with it

Keep it — the team recommends it, and a hand-rolled pair of listeners starts by reimplementing the fixes this one has already had. Just make the ErrorHandler underneath do the whole job:

typescript
export class GlobalErrorHandler implements ErrorHandler {
  private readonly analytics = inject(AnalyticsService);

  handleError(error: unknown) {
    this.analytics.report(error);
    console.error(error); // nothing else prints this any more
  }
}

And if you want the browser’s own reporting back, write your own listeners without preventDefault and drop the provider. The docs say that in one line, and it reads as a formality until you know what the line is for.

Every version that changed it

The whole history, so you can tell which behaviour the version you are on actually has:

  • 20.0 — the provider itself (#60704), with Zone.root.run around registration already in place (#60944).
  • 20.1 — the synthetic Error for an ErrorEvent without one (#62081), and the setTimeout rethrow when the injector is destroyed (#61886).
  • 21.0 — the handler call moved outside the zone (#63404).
  • 22.x — nothing. The file has one typo fix in a dev-mode token name since 21.0.3, and no behaviour change.

Read also