DEV Community

Cover image for Angular's provideBrowserGlobalErrorListeners: The Missing Piece in Your Zoneless Error Strategy
Rajat
Rajat

Posted on

Angular's provideBrowserGlobalErrorListeners: The Missing Piece in Your Zoneless Error Strategy

How one small provider can save your production app from silent, untracked errors — and why most Angular teams miss it.


Before you scroll: If you're moving your Angular app to zoneless change detection and you haven't audited your error handling strategy yet, this article is for you. Stick around — your monitoring tools might be lying to you right now.


Introduction

Have you ever deployed what felt like a rock-solid Angular app, only to get a support ticket about an error that never showed up in your monitoring dashboard? You open Sentry, Datadog, or your custom logger — and nothing. No trace. No breadcrumb. The error happened, your users saw it, but your error handler was completely silent.

If you recently migrated to zoneless Angular (or you're planning to), there's a very good chance this is already happening to you.

This article dives deep into provideBrowserGlobalErrorListeners — a relatively new Angular API that plugs a critical gap in global error handling for zoneless applications. By the time you finish reading, you will understand:

  • Why zone.js was quietly doing more than just change detection
  • What breaks in your error pipeline when you remove it
  • How provideBrowserGlobalErrorListeners restores that pipeline the right way
  • How to build a production-grade custom error handler from scratch
  • How to integrate it with Sentry, Datadog, or any monitoring tool
  • How to write unit tests that validate your error handling strategy

Let's get into it.


Got a story about a silent error in production that cost you a weekend? Drop it in the comments — I read every one of them.


What Is provideBrowserGlobalErrorListeners?

provideBrowserGlobalErrorListeners is a provider function introduced in Angular's core package. Its job is straightforward: it registers listeners on the browser's global window.onerror and window.unhandledrejection events, and routes those errors into Angular's ErrorHandler.

// Simplified mental model of what this provider does internally
window.addEventListener('error', (event) => {
  errorHandler.handleError(event.error);
});

window.addEventListener('unhandledrejection', (event) => {
  errorHandler.handleError(event.reason);
});
Enter fullscreen mode Exit fullscreen mode

Simple enough. But why did Angular need to ship this as an explicit API? To answer that, we need to talk about the deal you were unknowingly making with zone.js.


The Hidden Contract with zone.js

When Angular apps run with zone.js (which was the default for years), your ErrorHandler worked like magic. Any async error — whether inside a Promise, a setTimeout, an RxJS observable chain, or a plain event handler — would reliably land in your ErrorHandler.handleError() method. No extra configuration needed.

That was not Angular being clever. That was zone.js doing the heavy lifting.

Here's what zone.js was actually doing under the hood:

  1. Monkey-patching async APIs — It overwrote browser-native functions like Promise, setTimeout, setInterval, addEventListener, and others to wrap them in an execution "zone."
  2. Tracking async execution — Every async callback ran inside a zone context that could observe its lifecycle.
  3. Intercepting unhandled errors — When an error escaped a zone without being caught, zone.js intercepted it before it reached the browser's global scope.
  4. Forwarding to NgZone — Angular's NgZone listened for these zone-level errors and forwarded them to ErrorHandler.

So your ErrorHandler was receiving a curated, intercepted stream of errors — not raw browser events. It never had to care about window.onerror or unhandledrejection because zone.js was translating everything into a format Angular understood.

When you remove zone.js, that translation layer disappears entirely.


What Breaks Without zone.js

When you switch to provideZonelessChangeDetection() and remove zone.js, here is exactly what changes in the error pipeline:

WITH zone.js:
Async Error
  --> zone.js intercepts
    --> NgZone catches
      --> ErrorHandler.handleError() [YOUR CODE RUNS]

WITHOUT zone.js:
Async Error
  --> Browser fires 'unhandledrejection' or 'error' event
    --> ... silence ...
      --> Console warning (if dev mode)
        --> Your ErrorHandler never sees it
Enter fullscreen mode Exit fullscreen mode

This means your custom ErrorHandler — the one that logs to Sentry, sends alerts to Slack, or shows a friendly toast message — will only receive synchronous errors happening within Angular's own rendering pipeline. Everything else disappears.

Template binding errors: still caught.
Failed HTTP calls not wrapped in a catchError: gone.
Unhandled promise rejections in services: gone.
Third-party library async errors: gone.

This is not a bug in Angular. It is the explicit trade-off of going zoneless. And provideBrowserGlobalErrorListeners is the explicit way to opt back into catching those global errors.


Setting Up provideBrowserGlobalErrorListeners

Let's walk through a complete, production-ready setup.

Step 1: Bootstrap with the provider

// main.ts
import {
  bootstrapApplication,
  provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';

bootstrapApplication(AppComponent, appConfig).catch((err) =>
  console.error(err)
);
Enter fullscreen mode Exit fullscreen mode
// app.config.ts
import {
  ApplicationConfig,
  provideZonelessChangeDetection,
  provideBrowserGlobalErrorListeners,
  ErrorHandler,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { GlobalErrorHandler } from './core/error-handler/global-error-handler.service';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    provideBrowserGlobalErrorListeners(), // <-- The key addition
    provideRouter(routes),
    {
      provide: ErrorHandler,
      useClass: GlobalErrorHandler,
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

Notice: provideBrowserGlobalErrorListeners() does not replace your ErrorHandler. It is a bridge that feeds global browser errors into whatever ErrorHandler is registered. These two work together.


Step 2: Build a production-grade custom ErrorHandler

// core/error-handler/global-error-handler.service.ts
import { ErrorHandler, inject, Injectable, NgZone } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { ErrorLoggingService } from './error-logging.service';
import { NotificationService } from '../notifications/notification.service';

@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
  private readonly loggingService = inject(ErrorLoggingService);
  private readonly notificationService = inject(NotificationService);

  handleError(error: unknown): void {
    // Separate client-side errors from HTTP errors
    if (error instanceof HttpErrorResponse) {
      this.handleHttpError(error);
    } else if (error instanceof Error) {
      this.handleClientError(error);
    } else {
      this.handleUnknownError(error);
    }
  }

  private handleHttpError(error: HttpErrorResponse): void {
    const message = this.extractHttpMessage(error);
    console.error('[HTTP Error]', error.status, message);

    this.loggingService.logError({
      type: 'http',
      status: error.status,
      message,
      url: error.url ?? 'unknown',
    });

    if (error.status === 0) {
      this.notificationService.showError('Network error. Please check your connection.');
    } else if (error.status >= 500) {
      this.notificationService.showError('Something went wrong on our end. We are working on it.');
    }
  }

  private handleClientError(error: Error): void {
    console.error('[Client Error]', error.message, error.stack);

    this.loggingService.logError({
      type: 'client',
      message: error.message,
      stack: error.stack,
    });

    // Show a generic user-facing message — never expose raw stack traces
    this.notificationService.showError('An unexpected error occurred. Please try again.');
  }

  private handleUnknownError(error: unknown): void {
    console.error('[Unknown Error]', error);

    this.loggingService.logError({
      type: 'unknown',
      message: String(error),
    });
  }

  private extractHttpMessage(error: HttpErrorResponse): string {
    if (error.error instanceof ErrorEvent) {
      return error.error.message;
    }
    return error.message;
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Wire in your logging service

// core/error-handler/error-logging.service.ts
import { Injectable } from '@angular/core';
import { environment } from '../../environments/environment';

interface ErrorPayload {
  type: 'http' | 'client' | 'unknown';
  message: string;
  status?: number;
  url?: string;
  stack?: string;
}

@Injectable({ providedIn: 'root' })
export class ErrorLoggingService {

  logError(payload: ErrorPayload): void {
    if (!environment.production) {
      // In development, just log to console with structured data
      console.table(payload);
      return;
    }

    // In production, send to your monitoring tool
    this.sendToSentry(payload);
    this.sendToDatadog(payload);
  }

  private sendToSentry(payload: ErrorPayload): void {
    // Sentry integration example
    // Sentry.captureException(new Error(payload.message), {
    //   tags: { errorType: payload.type },
    //   extra: { status: payload.status, url: payload.url },
    // });
  }

  private sendToDatadog(payload: ErrorPayload): void {
    // Datadog RUM integration example
    // datadogRum.addError(new Error(payload.message), {
    //   errorType: payload.type,
    //   httpStatus: payload.status,
    // });
  }
}
Enter fullscreen mode Exit fullscreen mode

Integrating with Sentry in a Real Application

Here's a practical, self-contained Sentry integration:

// core/error-handler/sentry-error-handler.service.ts
import * as Sentry from '@sentry/angular';
import { ErrorHandler, Injectable, inject } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';

@Injectable()
export class SentryErrorHandler implements ErrorHandler {

  handleError(error: unknown): void {
    // Do not report 4xx HTTP errors to Sentry — those are user-facing issues
    if (error instanceof HttpErrorResponse && error.status < 500 && error.status >= 400) {
      console.warn('[Expected HTTP Error]', error.status, error.url);
      return;
    }

    const extractedError = this.extractError(error);
    Sentry.captureException(extractedError, {
      tags: {
        angular_error: 'true',
        error_type: error instanceof HttpErrorResponse ? 'http' : 'client',
      },
    });

    // Always re-throw or log locally in development
    if (!environment.production) {
      throw extractedError;
    }
  }

  private extractError(error: unknown): Error {
    if (error instanceof Error) return error;
    if (error instanceof HttpErrorResponse) {
      return new Error(`HTTP ${error.status}: ${error.message}`);
    }
    return new Error(String(error));
  }
}
Enter fullscreen mode Exit fullscreen mode

And in your config:

// app.config.ts
import * as Sentry from '@sentry/angular';
import {
  ApplicationConfig,
  ErrorHandler,
  provideZonelessChangeDetection,
  provideBrowserGlobalErrorListeners,
} from '@angular/core';

Sentry.init({
  dsn: 'YOUR_SENTRY_DSN',
  environment: environment.name,
  tracesSampleRate: 0.2,
});

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    provideBrowserGlobalErrorListeners(),
    {
      provide: ErrorHandler,
      useClass: SentryErrorHandler,
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

Behavior Comparison: zone.js vs. Zoneless + Provider

Error Source zone.js Zoneless (no provider) Zoneless + provider
Template binding error Caught Caught Caught
Synchronous service error Caught Caught Caught
Unhandled Promise rejection Caught NOT caught Caught
setTimeout error Caught NOT caught Caught
HTTP error (no catchError) Caught Caught via HttpClient Caught via HttpClient
Third-party async error Caught NOT caught Caught

The provider restores everything in the third column that says "NOT caught."


Conceptual Flow Diagram

Browser Runtime
│
├── Synchronous Error (template / DI)
│     └── Angular catches directly
│           └── ErrorHandler.handleError()
│
├── Unhandled Promise rejection
│     └── Browser fires: window.unhandledrejection
│           └── [provideBrowserGlobalErrorListeners intercepts]
│                 └── ErrorHandler.handleError()
│
└── Script / runtime error
      └── Browser fires: window.onerror
            └── [provideBrowserGlobalErrorListeners intercepts]
                  └── ErrorHandler.handleError()
Enter fullscreen mode Exit fullscreen mode

Pitfalls and Edge Cases

1. Duplicate error reporting

If you manually add window.addEventListener('unhandledrejection', ...) anywhere in your codebase AND use provideBrowserGlobalErrorListeners, you will handle the same error twice. Audit your codebase before adding the provider.

// DANGER: This will double-report errors
window.addEventListener('unhandledrejection', (event) => {
  myLogger.log(event.reason); // fires once
});

// And then provideBrowserGlobalErrorListeners also fires
// --> ErrorHandler.handleError() also fires
Enter fullscreen mode Exit fullscreen mode

Pick one approach. Let provideBrowserGlobalErrorListeners be the single point of entry.


2. SSR considerations

provideBrowserGlobalErrorListeners hooks into browser-specific globals (window.onerror, window.addEventListener). These do not exist in a Node.js environment. If you are using Angular Universal or @angular/ssr, make sure this provider is only included in your browser config, not your server config.

// app.config.browser.ts (browser-specific config)
import { mergeApplicationConfig } from '@angular/core';
import { provideBrowserGlobalErrorListeners } from '@angular/core';
import { appConfig } from './app.config';

export const appBrowserConfig = mergeApplicationConfig(appConfig, {
  providers: [
    provideBrowserGlobalErrorListeners(), // safe here — browser only
  ],
});
Enter fullscreen mode Exit fullscreen mode
// app.config.server.ts (server config — do NOT include the provider here)
import { mergeApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';

export const appServerConfig = mergeApplicationConfig(appConfig, {
  providers: [provideServerRendering()],
});
Enter fullscreen mode Exit fullscreen mode

3. Swallowing errors in production

A common mistake is catching everything in handleError without re-throwing during development. This makes debugging nearly impossible.

handleError(error: unknown): void {
  this.loggingService.logError(error);

  // Always surface errors in development
  if (!environment.production) {
    throw error; // Re-throw so DevTools shows a real stack trace
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Angular v20+ includes it by default for new projects

From Angular v20 onwards, the CLI includes provideBrowserGlobalErrorListeners automatically in new project scaffolds. If you are upgrading an existing project, you need to add it manually — the migration guide does not always flag this.


Writing Unit Tests for Your Error Handler

Testing error handling is one of those things teams consistently skip. Here is a solid testing setup.

Testing the custom ErrorHandler

// global-error-handler.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { ErrorHandler } from '@angular/core';
import { GlobalErrorHandler } from './global-error-handler.service';
import { ErrorLoggingService } from './error-logging.service';
import { NotificationService } from '../notifications/notification.service';
import { HttpErrorResponse } from '@angular/common/http';

describe('GlobalErrorHandler', () => {
  let handler: GlobalErrorHandler;
  let loggingSpy: jasmine.SpyObj<ErrorLoggingService>;
  let notificationSpy: jasmine.SpyObj<NotificationService>;

  beforeEach(() => {
    loggingSpy = jasmine.createSpyObj('ErrorLoggingService', ['logError']);
    notificationSpy = jasmine.createSpyObj('NotificationService', ['showError']);

    TestBed.configureTestingModule({
      providers: [
        { provide: ErrorHandler, useClass: GlobalErrorHandler },
        { provide: ErrorLoggingService, useValue: loggingSpy },
        { provide: NotificationService, useValue: notificationSpy },
      ],
    });

    handler = TestBed.inject(ErrorHandler) as GlobalErrorHandler;
  });

  it('should log client-side errors and show notification', () => {
    const error = new Error('Something went wrong');
    handler.handleError(error);

    expect(loggingSpy.logError).toHaveBeenCalledWith(
      jasmine.objectContaining({ type: 'client', message: 'Something went wrong' })
    );
    expect(notificationSpy.showError).toHaveBeenCalled();
  });

  it('should handle HTTP 500 errors and show notification', () => {
    const httpError = new HttpErrorResponse({ status: 500, statusText: 'Internal Server Error' });
    handler.handleError(httpError);

    expect(loggingSpy.logError).toHaveBeenCalledWith(
      jasmine.objectContaining({ type: 'http', status: 500 })
    );
    expect(notificationSpy.showError).toHaveBeenCalled();
  });

  it('should handle HTTP 404 gracefully', () => {
    const httpError = new HttpErrorResponse({ status: 404, url: '/api/user' });
    handler.handleError(httpError);

    expect(loggingSpy.logError).toHaveBeenCalled();
  });

  it('should handle unknown error types', () => {
    handler.handleError('just a string error');

    expect(loggingSpy.logError).toHaveBeenCalledWith(
      jasmine.objectContaining({ type: 'unknown' })
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing that unhandledrejection is caught end-to-end

// integration: verify global listener is active
it('should catch unhandled promise rejections via global listener', async () => {
  const handleErrorSpy = spyOn(TestBed.inject(ErrorHandler), 'handleError');

  // Simulate an unhandled rejection
  const rejectionEvent = new PromiseRejectionEvent('unhandledrejection', {
    promise: Promise.reject(new Error('async failure')),
    reason: new Error('async failure'),
  });

  window.dispatchEvent(rejectionEvent);

  // Allow microtask queue to flush
  await Promise.resolve();

  expect(handleErrorSpy).toHaveBeenCalledWith(
    jasmine.objectContaining({ message: 'async failure' })
  );
});
Enter fullscreen mode Exit fullscreen mode

How This Fits into the Modern Angular Architecture

Angular's trajectory is clear: signals-based reactivity, standalone component architecture, and a zoneless future. provideBrowserGlobalErrorListeners is part of that story.

In the zoneless world, Angular trades implicit magic for explicit control. You get:

  • Smaller bundles (no zone.js overhead)
  • More predictable change detection
  • Better performance in CPU-intensive scenarios

But explicit control means you have to own things that used to be automatic. Error forwarding is one of them. provideBrowserGlobalErrorListeners is how Angular says: "we recognize you still need this — here is the explicit, supported way to get it."

Think of it as part of your application's provider composition — sitting alongside provideZonelessChangeDetection(), provideRouter(), and your custom ErrorHandler.


Traditional Approaches vs. The Right Way

Let's be direct about why you should not roll your own solution:

Option A: Raw window.onerror

// Works, but disconnected from Angular's DI system
window.onerror = (message, source, line, col, error) => {
  myLogger.log(error); // No access to Angular services here
};
Enter fullscreen mode Exit fullscreen mode

Option B: Manual APP_INITIALIZER

// Fragile and verbose — you have to manage cleanup yourself
{
  provide: APP_INITIALIZER,
  useFactory: (handler: ErrorHandler) => () => {
    window.addEventListener('unhandledrejection', (e) => handler.handleError(e.reason));
    window.addEventListener('error', (e) => handler.handleError(e.error));
  },
  deps: [ErrorHandler],
  multi: true,
}
Enter fullscreen mode Exit fullscreen mode

Option C: provideBrowserGlobalErrorListeners (correct approach)

// Clean, composable, officially maintained, SSR-aware
provideBrowserGlobalErrorListeners()
Enter fullscreen mode Exit fullscreen mode

Option C is not just shorter — it is the only option the Angular team will maintain going forward. Options A and B create hidden state that is easy to get wrong and hard to test.


Bonus Tips

Tip 1: Filter noise before logging.
Not every error deserves a Sentry ticket. HTTP 401 (unauthorized) and 403 (forbidden) errors are often expected. Add a filter layer in your handler before calling your logging service.

Tip 2: Add context to your errors.
A raw stack trace is rarely enough. Add user context (anonymized), current route, and app version to every error payload. This dramatically speeds up debugging.

this.loggingService.logError({
  ...payload,
  route: this.router.url,
  appVersion: environment.version,
  userId: this.authService.currentUserId(), // anonymized
});
Enter fullscreen mode Exit fullscreen mode

Tip 3: Use structured logging formats.
If you are building your own logging backend, structure your error payloads as JSON from the start. This makes them queryable in tools like Elasticsearch or CloudWatch.

Tip 4: Never show raw error messages to end users.
Map technical errors to user-friendly messages. "TypeError: Cannot read properties of undefined" means nothing to your users. "Something went wrong. Please refresh and try again." is actionable.

Tip 5: Test your error handler in production-like conditions.
Your development environment might mask errors that only appear in production builds. Add a dedicated test route that intentionally triggers different error types so your QA team can verify the full pipeline.


Recap

Here is everything we covered, condensed:

  • zone.js was intercepting async errors and routing them to Angular's ErrorHandler behind the scenes.
  • Removing zone.js (via provideZonelessChangeDetection) cuts that pipeline. Async errors become invisible to Angular.
  • provideBrowserGlobalErrorListeners explicitly re-establishes that pipeline by listening to window.onerror and window.unhandledrejection and forwarding them to your ErrorHandler.
  • It belongs in your bootstrapApplication providers alongside provideZonelessChangeDetection.
  • It does not work in SSR — keep it in browser-only config files.
  • Angular v20+ new projects get this automatically. Existing projects upgrading to zoneless must add it manually.
  • A good error strategy combines this provider with a typed custom ErrorHandler, a logging service, and meaningful user notifications.

The bottom line: if you are going zoneless and you are not using this provider, your error monitoring is broken right now — you just do not know it yet.


Suggested Titles for this Article

  1. provideBrowserGlobalErrorListeners: The Angular API Your Zoneless App Can't Live Without
  2. Why Your Angular Error Handler Goes Silent in Zoneless Mode — and the One-Line Fix
  3. Mastering Angular Global Error Handling in 2025: Zoneless, Standalone, and Production-Ready
  4. The Invisible Bug in Every Angular Zoneless Migration (And How to Fix It)
  5. Angular Error Handling Without zone.js: A Complete Production Guide

Let's Talk

What did you think of this one?

Have you already hit this issue in production? I would genuinely love to hear your story. Did you catch it before deployment, or did users find it first? Drop a comment below — that kind of real-world context helps everyone.

Here is a specific question to kick off the discussion:
When you migrated to zoneless (or when you do), what was the first thing you audited in your error handling pipeline? Was it HTTP errors, async promises, or something else entirely?


If this saved you from a sleepless debugging session, consider giving it a clap — it helps other developers find articles like this on Medium, and honestly, it makes my day.

Let's keep building better apps — one deliberate architecture decision at a time.

Follow Me for More Angular & Frontend Goodness:

I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides.

  • 💼 LinkedIn — Let’s connect professionally
  • 🎥 Threads — Short-form frontend insights
  • 🐦 X (Twitter) — Developer banter + code snippets
  • 👥 BlueSky — Stay up to date on frontend trends
  • 🌟 GitHub Projects — Explore code in action
  • 🌐 Website — Everything in one place
  • 📚 Medium Blog — Long-form content and deep-dives
  • 💬 Dev Blog — Free Long-form content and deep-dives
  • ✉️ Substack — Weekly frontend stories & curated resources
  • 🧩 Portfolio — Projects, talks, and recognitions
  • ✍️ Hashnode — Developer blog posts & tech discussions
  • ✍️ Reddit — Developer blog posts & tech discussions

Top comments (0)