Home | Send Feedback | Share on Bluesky |

Global error handler in Angular

Published: 5. October 2018  •  javascript

Angular forwards unexpected framework-level errors to the application's root ErrorHandler. That makes ErrorHandler a good place to report failures to your logging infrastructure, while you still keep local recovery logic close to the code that can actually handle an error.

In a standalone application, register the custom handler when you call bootstrapApplication(). In current Angular versions it also makes sense to enable the browser global error listeners so unhandled error and unhandledrejection events are forwarded to the same handler.

bootstrapApplication(AppComponent, {
  providers: [
    provideZoneChangeDetection(),
    provideBrowserGlobalErrorListeners(),
    provideIonicAngular(),
    {provide: RouteReuseStrategy, useClass: IonicRouteStrategy},
    {provide: ErrorHandler, useClass: AppGlobalErrorHandler},
    provideRouter(routes, withHashLocation())
  ]
});

Reporting errors to a server

The sample back end is a small Spring Boot controller with one POST endpoint that accepts a list of client-side error reports.

public class ErrorController {

  @PostMapping("/clientError")
  public void clientError(@RequestBody List<ClientError> clientErrors) {
    for (ClientError cl : clientErrors) {
      System.out.println(cl);
    }
  }

ErrorController.java

The demo application only prints the reports, but the same endpoint could store them in a database, send an email, or create an issue in an error tracking system.

The custom Angular error handler injects a small service that manages the IndexedDB queue.

@Injectable()
export class AppGlobalErrorHandler implements ErrorHandler {
  private readonly clientErrorService = inject(ClientErrorService);

app.global.errorhandler.ts

Inside handleError(), the application creates a structured payload with a timestamp, browser metadata, and a parsed stack trace. Instead of pulling in a third-party dependency, the sample parses Error.stack with a small helper that understands the common V8 and Firefox-style stack formats and converts them into a simple JSON-friendly structure.

The payload also includes navigator.onLine and optional values from the Network Information API, such as effectiveType and downlink, when the browser exposes them. These values are only hints, but they can still be useful when you inspect reports later.

async handleError(error: any): Promise<void> {
  console.error(error);

  const body = JSON.stringify(this.createErrorReport(error));

  const wasOK = await this.sendError(body);
  if (!wasOK) {
    await this.clientErrorService.store(body);
    this.scheduleRetry();
  }
}

The current implementation is here:

  async handleError(error: any): Promise<void> {
    console.error(error);

    const body = JSON.stringify(this.createErrorReport(error));

    const wasOK = await this.sendError(body);
    if (!wasOK) {
      await this.clientErrorService.store(body);
      this.scheduleRetry();
    }
  }

app.global.errorhandler.ts

The parsing code lives in the same handler. It splits the raw stack into lines, skips the error header, and then applies a couple of regular expressions to extract file name, function name, line number, and column number.

  private parseStackTrace(error: Error): ErrorStackFrame[] {
    if (!error.stack) {
      return [];
    }

    return error.stack
      .split('\n')
      .map(line => line.trim())
      .filter(line => line.length > 0 && !this.isErrorHeaderLine(line))
      .map(line => this.parseStackFrame(line))
      .filter((frame): frame is ErrorStackFrame => frame !== null);
  }

  private isErrorHeaderLine(line: string): boolean {
    return !line.startsWith('at ') && !line.includes('@');
  }

  private parseStackFrame(line: string): ErrorStackFrame | null {
    return this.parseV8StackFrame(line) ?? this.parseFirefoxStackFrame(line);
  }

  private parseV8StackFrame(line: string): ErrorStackFrame | null {
    const withFunctionMatch = /^at\s+(.*?)\s+\((.+):(\d+):(\d+)\)$/.exec(line);
    if (withFunctionMatch) {
      const [, functionName, fileName, lineNumber, columnNumber] = withFunctionMatch;
      return {
        functionName,
        fileName,
        lineNumber: Number(lineNumber),
        columnNumber: Number(columnNumber)
      };
    }

    const withoutFunctionMatch = /^at\s+(.+):(\d+):(\d+)$/.exec(line);
    if (withoutFunctionMatch) {
      const [, fileName, lineNumber, columnNumber] = withoutFunctionMatch;
      return {
        fileName,
        lineNumber: Number(lineNumber),
        columnNumber: Number(columnNumber)
      };
    }

    return null;
  }

  private parseFirefoxStackFrame(line: string): ErrorStackFrame | null {
    const withFunctionMatch = /^(.*?)@(.+):(\d+):(\d+)$/.exec(line);
    if (withFunctionMatch) {
      const [, functionName, fileName, lineNumber, columnNumber] = withFunctionMatch;
      return {
        functionName: functionName || undefined,
        fileName,
        lineNumber: Number(lineNumber),
        columnNumber: Number(columnNumber)
      };
    }

    return null;
  }

app.global.errorhandler.ts

On the server, Spring maps the request body to a java.util.List, so the client can send either one report or a batch of queued reports with the same endpoint.

Handling offline periods

If your application keeps running with an unstable connection, you also want to capture errors that happen while requests to the reporting endpoint fail.

Background Sync is a good option for a PWA with a service worker and a browser support matrix centered on Chromium-based browsers. For a regular Angular application that needs broader browser coverage, storing failed reports in IndexedDB and retrying them in the foreground is a simple and portable approach.

This example uses Dexie.js instead of the raw IndexedDB API. The database contains one object store named errors, where each record stores one serialized error report.

export class ClientErrorDb extends Dexie {
  errors!: Dexie.Table<ClientError, number>;

  constructor() {
    super('ClientErrors');
    this.version(1).stores({
      errors: '++id'
    });
  }

clientErrorDb.ts

See also my blog post about Dexie.js and TypeScript.

The service that wraps Dexie exposes three methods: store a report, delete a batch after a successful upload, and fetch all queued reports.

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

  private db: ClientErrorDb;

  constructor() {
    this.db = new ClientErrorDb();
  }

  async store(body: string): Promise<void> {
    await this.db.errors.add({error: body});
  }

  async delete(ids: number[]): Promise<void> {
    await this.db.errors.bulkDelete(ids);
  }

  async getAll(): Promise<ClientError[]> {
    return this.db.errors.toArray();
  }

}

clientError.service.ts

When the application starts, the error handler immediately tries to flush stored reports. It also listens for the browser's online event and triggers another flush when connectivity comes back. The online signal should be treated as a hint and not as a guarantee that the reporting endpoint is reachable, so the code still always attempts the actual fetch() call.

  constructor() {
    void this.sendStoredErrors();
    window.addEventListener('online', () => {
      this.clearRetryTimer();
      void this.sendStoredErrors();
    });
  }

app.global.errorhandler.ts

The retry loop fetches all queued reports from IndexedDB, posts them in one batch, and deletes them only after the server responds successfully. If the request still fails, the handler schedules another run and increases the delay exponentially up to 32 minutes.

  private async sendStoredErrors(): Promise<void> {
    if (this.isRetryRunning) {
      return;
    }

    this.isRetryRunning = true;
    const retry = async () => {
      const errors = await this.clientErrorService.getAll();
      if (errors.length === 0) {
        this.isRetryRunning = false;
        this.retryDelayMinutes = 1;
        return;
      }

      const wasOK = await this.sendError(errors.map(error => error.error));
      if (wasOK) {
        const deleteIds: number[] = [];
        for (const error of errors) {
          if (error.id !== undefined) {
            deleteIds.push(error.id);
          }
        }
        await this.clientErrorService.delete(deleteIds);
        this.isRetryRunning = false;
        this.retryDelayMinutes = 1;
        return;
      }

      this.isRetryRunning = false;
      this.scheduleRetry();
    };

    await retry();
  }

app.global.errorhandler.ts

The sendError() method accepts either one serialized report or an array of reports. Before sending the payload, it wraps everything in a JSON array so the server can process single uploads and batch uploads with the same controller method.

  private async sendError(errors: string[] | string): Promise<boolean> {
    try {
      const body = Array.isArray(errors) ? `[${errors.join(',')}]` : `[${errors}]`;

      const response = await fetch(`${environment.serverURL}/clientError`, {
        method: 'POST',
        body,
        headers: {
          'content-type': 'application/json'
        }
      });
      if (response.ok) {
        return true;
      }
    } catch (error) {
      console.log(error);
    }

    return false;
  }

app.global.errorhandler.ts

Summary

With a custom ErrorHandler, Angular gives you one place to report unexpected application failures. By combining that handler with IndexedDB, the online event, and exponential backoff, you can keep collecting client-side error reports even when the network is unreliable.

You can find the complete source for this example on GitHub: https://github.com/ralscha/blog/tree/master/ngerrorhandler