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.jswas quietly doing more than just change detection - What breaks in your error pipeline when you remove it
- How
provideBrowserGlobalErrorListenersrestores 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);
});
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:
-
Monkey-patching async APIs — It overwrote browser-native functions like
Promise,setTimeout,setInterval,addEventListener, and others to wrap them in an execution "zone." - Tracking async execution — Every async callback ran inside a zone context that could observe its lifecycle.
-
Intercepting unhandled errors — When an error escaped a zone without being caught,
zone.jsintercepted it before it reached the browser's global scope. -
Forwarding to NgZone — Angular's
NgZonelistened for these zone-level errors and forwarded them toErrorHandler.
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
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)
);
// 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,
},
],
};
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;
}
}
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,
// });
}
}
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));
}
}
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,
},
],
};
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()
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
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
],
});
// 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()],
});
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
}
}
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' })
);
});
});
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' })
);
});
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.jsoverhead) - 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
};
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,
}
Option C: provideBrowserGlobalErrorListeners (correct approach)
// Clean, composable, officially maintained, SSR-aware
provideBrowserGlobalErrorListeners()
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
});
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.jswas intercepting async errors and routing them to Angular'sErrorHandlerbehind the scenes. - Removing
zone.js(viaprovideZonelessChangeDetection) cuts that pipeline. Async errors become invisible to Angular. -
provideBrowserGlobalErrorListenersexplicitly re-establishes that pipeline by listening towindow.onerrorandwindow.unhandledrejectionand forwarding them to yourErrorHandler. - It belongs in your
bootstrapApplicationproviders alongsideprovideZonelessChangeDetection. - 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
provideBrowserGlobalErrorListeners: The Angular API Your Zoneless App Can't Live Without- Why Your Angular Error Handler Goes Silent in Zoneless Mode — and the One-Line Fix
- Mastering Angular Global Error Handling in 2025: Zoneless, Standalone, and Production-Ready
- The Invisible Bug in Every Angular Zoneless Migration (And How to Fix It)
- 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)