You must send every uncaught Angular error to a monitoring endpoint; how would you write the custom ErrorHandler, and what pitfalls would you avoid?
answer
- the handler must never throw
- not every error is an Error
- reporting can itself fail
- cap and deduplicate
- add context, skip HttpClient
basics
~20 sImplement ErrorHandler, normalise the value into name, message and stack, add context such as the current URL, and send it with navigator.sendBeacon or fetch. Wrap everything in try/catch, deduplicate and cap reports, and keep console output.
solid answer
~40 sI write a class implementing `ErrorHandler` and provide it with `useClass` in `ApplicationConfig`, next to `provideBrowserGlobalErrorListeners()` so window errors reach it too. In `handleError` I keep `console.error`, normalise the value — it may be a string or an `HttpErrorResponse`, not an `Error` — and build a small payload: name, message, stack, current router URL, build version. I send it with `navigator.sendBeacon` or `fetch` with `keepalive`, not `HttpClient`, so interceptors and their own failures are not involved. The pitfalls: the handler must never throw, so everything is in `try`/`catch`; the report itself must not produce a new unhandled rejection, or the global listener feeds it straight back in a loop; and an error thrown on every change detection pass can flood the endpoint, so I fingerprint, deduplicate and cap per page load.
code
ts · 34 linesimport { ErrorHandler, Injectable, inject, isDevMode } from '@angular/core';
import { Router } from '@angular/router';
const MAX_REPORTS_PER_PAGE = 20;
@Injectable()
export class MonitoringErrorHandler implements ErrorHandler {
private readonly router = inject(Router);
private readonly seen = new Set<string>();
handleError(error: unknown): void {
console.error(error);
if (isDevMode()) {
return;
}
try {
const err = error instanceof Error ? error : new Error(String(error), { cause: error });
const fingerprint = `${err.name}:${err.message}`;
if (this.seen.has(fingerprint) || this.seen.size >= MAX_REPORTS_PER_PAGE) {
return;
}
this.seen.add(fingerprint);
const payload = JSON.stringify({
name: err.name,
message: err.message,
stack: err.stack,
url: this.router.url,
});
navigator.sendBeacon('/monitoring/errors', payload);
} catch (reportingFailure) {
console.error('Error reporting failed', reportingFailure);
}
}
}go deeper
Recall the pieces: a class implementing handleError, registered under the ErrorHandler token, that sends a small payload.
Explain normalising non-Error values, adding context such as the URL, and why reporting must not throw.
Show the guards: loop prevention with the global listeners, flood control for errors raised on every check, and reporting outside HttpClient.
Set the policy: sampling, privacy rules for payloads, release tagging and which error rates page someone.
## The goal Angular's default `ErrorHandler` only logs to the console, so production errors stay on users' machines. Sending them to a monitoring endpoint means replacing the handler with one that **reports**, while staying cheap, safe and silent for the user. ## The design, step by step 1. **Provide the class** under the `ErrorHandler` token in `ApplicationConfig`, and keep `provideBrowserGlobalErrorListeners()` so errors from timers, promises and third-party callbacks reach the same handler. 2. **Keep console output.** Call `console.error` first; developers still need the stack locally. 3. **Normalise the value.** `handleError` receives `unknown` in practice: an `Error`, a string, an `HttpErrorResponse`, a plain object. Convert it to name, message and stack before serialising. 4. **Add context.** The current router URL, the build or release identifier and the user agent turn a stack trace into something you can reproduce. Never add personal data or tokens. 5. **Deduplicate and cap.** Fingerprint by name and message (optionally the top stack frame), skip repeats, and stop after a fixed number of reports per page load. 6. **Send out of band.** Use `navigator.sendBeacon` or `fetch(url, { method: 'POST', keepalive: true })`; both survive a page unload and bypass Angular's HTTP stack. 7. **Wrap everything in `try`/`catch`** and swallow reporting failures after logging them locally. ## Pitfalls and why they bite | Pitfall | What happens | Guard | |---|---|---| | Handler throws | The original error report is lost and a new error surfaces | `try`/`catch` around all reporting code | | Report promise rejects unhandled | The `unhandledrejection` listener sends it back to the handler, which reports again | Catch the `fetch` promise, or use `sendBeacon` | | Error in a template binding | It is thrown on every change detection pass, flooding the endpoint | Fingerprint, deduplicate, cap per page | | Reporting through `HttpClient` | Interceptors run: auth refreshes, redirects, retries, and their errors re-enter the handler | Plain browser APIs for reporting | | Non-`Error` values | `JSON.stringify` of an `Error` gives `{}`; a string has no stack | Normalise before serialising | | Heavy dependencies | The handler fails when the thing that broke is one of its dependencies | Inject little; Angular resolves the handler lazily, keep it that way | ## Choosing what to send A useful report is small and actionable: - **Error identity**: name, message, and the stack, ideally mapped back to source with the build's source maps on the monitoring side. - **Where**: the router URL at the time of the error, not the full URL with query parameters if those may carry personal data. - **Which build**: a release identifier injected at build time, so a spike can be tied to a deploy. - **Environment**: user agent and viewport are often enough; avoid collecting anything you would not want in a log. Leave out request bodies, form contents and tokens: an error report is still data leaving the user's browser. ## Zone and change detection notes Angular calls the handler outside the Angular zone. In a zone-based app that means work inside it does not trigger change detection through zone.js; if the handler must show a notice, write to a signal-backed service and let the component that renders it pick it up. In zoneless applications, the default since v21, the listeners from `provideBrowserGlobalErrorListeners()` are the only route by which errors from free-standing async code reach the handler at all. ## Development versus production - In development, log generously and skip sending. - In production, send, but sample if volume is high. - In unit tests, `TestBed` rethrows errors that reach the handler by default, so do not rely on the handler to keep tests green. ## What makes this a senior answer The code is short; the judgement is in the guards. Interviewers listen for: never throwing, the feedback loop between reporting and the global listeners, flood control for errors raised on every change detection pass, avoiding `HttpClient` inside the handler, and adding enough context to act on a report without leaking user data.
- Why can reporting with fetch create an infinite loop?If the `fetch` promise rejects — offline, blocked by an extension — and nothing catches it, the browser raises `unhandledrejection`. With `provideBrowserGlobalErrorListeners()` that event is forwarded to the `ErrorHandler`, which reports again, which fails again. Catch the promise or use `sendBeacon`, which does not return a promise.
- Why not just use HttpClient to post the error?`HttpClient` runs your interceptors: token refresh, redirects on 401, retries, logging. Any of them can fail or throw and route a new error back to the handler, and they add DI dependencies to the one service that must keep working when the app is broken. A plain browser API keeps reporting independent.
saying these in an interview costs you the question
- Rethrow the error from handleError so it is not lost
- Post errors with HttpClient so interceptors add auth headers
- Every error passed to handleError has a stack property
- Report every occurrence; deduplication hides real problems
- Drop console.error once errors are sent to monitoring