Error Tracking for Angular Apps: Zone.js Errors, RxJS Failures, and Route Drop-Off
EngineeringJune 24, 202612 min read

Error Tracking for Angular Apps: Zone.js Errors, RxJS Failures, and Route Drop-Off

Angular error tracking with Zone.js, RxJS, and route analytics in one 5KB script.

The error showed up in production three weeks before anyone noticed. TypeError: Cannot read properties of undefined (reading 'subscribe') — happening 847 times a day, exclusively on the checkout confirmation page, only for users who clicked "Apply Coupon" before their cart finished loading.

Nobody noticed because the app didn't crash. Angular's Zone.js caught the error, logged it to the console, and kept running. Users saw a spinner that never stopped. They refreshed. Tried again. Eventually gave up and went somewhere else.

I found it while digging through our analytics trying to figure out why checkout drop-off had climbed 8% over the past month. The funnel showed users reaching the confirmation step and then... nothing. No completion. No error page. Just gone.

Turned out the error was being swallowed by an RxJS catchError that returned an empty observable. Angular kept running. Users kept leaving. And our Sentry setup — which we thought was handling this — wasn't configured to catch errors from RxJS streams that never throw to the global handler.

That's the problem with Angular error tracking in 2026: Zone.js and RxJS play by different rules than vanilla JavaScript, and most error tracking tools expect window.onerror to catch everything. It doesn't. (I've ranted about this at two conferences now. Nobody listens until they've lost a week to a silent failure.)

What You'll Have by the End

A complete Angular error tracking setup that captures:

  • Zone.js errors via a custom ErrorHandler
  • RxJS stream failures that don't bubble to the global scope
  • Route-level pageviews tied to the same session context
  • All three in one under-5KB script, with unified session IDs for correlation

The output: when someone drops off your checkout flow, you can filter to "sessions with errors" and see exactly which exception caused it — in 30 seconds instead of three weeks.

Prerequisites

  • Angular 15+ (tested through Angular 18)
  • Node.js 18+
  • A JustAnalytics account (free tier covers 100K events/month)
  • Basic familiarity with Angular's dependency injection

Step 1: Install the SDK and Add the Script

Install the browser SDK:

npm install @justanalytics/browser@^2.5.0

Add the tracking script to your index.html, right before the closing </body> tag:

<script
  src="https://cdn.justanalytics.app/script.js"
  data-site="your-site-id"
  data-auto-pageview="false"
  async
></script>

data-auto-pageview="false" is important. Angular's router handles navigation without full page loads, so we'll track pageviews manually. If you leave auto-pageview on, you'll double-count the initial load and miss all subsequent navigations.

You'll also want the TypeScript declarations. Add this to a src/types/global.d.ts file:

declare global {
  interface Window {
    ja?: (action: "pageview" | "event" | "error", ...args: unknown[]) => void;
  }
}

export {};

Now your IDE won't yell at you every time you call window.ja(). Small win, but it matters when you're trying to ship something by Friday.

Step 2: Create a Custom ErrorHandler

Angular's default ErrorHandler logs to console and moves on. We need one that sends errors to JustAnalytics while still letting Angular continue running.

Create src/app/core/error-handler.service.ts:

import { ErrorHandler, Injectable, Injector } from "@angular/core";
import { Router } from "@angular/router";

@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
  // Use Injector to avoid circular dependency with Router
  constructor(private injector: Injector) {}

  handleError(error: unknown): void {
    // Get current route for context
    let currentRoute = "/";
    try {
      const router = this.injector.get(Router);
      currentRoute = router.url;
    } catch {
      // Router not available during bootstrap — fine, use default
    }

    // Unwrap Zone.js wrapped errors
    const unwrapped = this.unwrapError(error);

    // Send to JustAnalytics
    if (typeof window !== "undefined" && window.ja) {
      window.ja("error", unwrapped, {
        route: currentRoute,
        angular_version: "18.0.0", // or inject from environment
        component: this.extractComponentName(unwrapped),
      });
    }

    // Still log to console for development
    console.error("Angular Error:", unwrapped);
  }

  private unwrapError(error: unknown): Error {
    // Zone.js wraps errors in a rejection object sometimes
    if (error && typeof error === "object") {
      if ("rejection" in error && error.rejection instanceof Error) {
        return error.rejection;
      }
      if ("error" in error && (error as { error: unknown }).error instanceof Error) {
        return (error as { error: Error }).error;
      }
    }
    if (error instanceof Error) {
      return error;
    }
    return new Error(String(error));
  }

  private extractComponentName(error: Error): string {
    // Try to extract component name from Angular's error formatting
    const match = error.message?.match(/in component\s+(\w+)/i);
    return match?.[1] ?? "unknown";
  }
}

The unwrapError function handles a Zone.js quirk: rejected promises get wrapped in an object with a rejection property rather than thrown directly. If you don't unwrap them, your error messages look like [object Object] instead of actual stack traces. Ask me how I found that out. (Two days. Two days of debugging an [object Object].)

Register it in your app module (or app.config.ts for standalone apps):

// app.module.ts
import { NgModule, ErrorHandler } from "@angular/core";
import { GlobalErrorHandler } from "./core/error-handler.service";

@NgModule({
  providers: [
    { provide: ErrorHandler, useClass: GlobalErrorHandler },
  ],
  // ...
})
export class AppModule {}

Or for standalone:

// app.config.ts
import { ApplicationConfig, ErrorHandler } from "@angular/core";
import { GlobalErrorHandler } from "./core/error-handler.service";

export const appConfig: ApplicationConfig = {
  providers: [
    { provide: ErrorHandler, useClass: GlobalErrorHandler },
  ],
};

At this point, any unhandled error in your Angular app — component lifecycle errors, template binding failures, service exceptions — flows through JustAnalytics with route context attached.

Step 3: Capture RxJS Stream Errors

Here's the part most tutorials skip — and what makes unified observability so valuable. Honestly, it's the part I wish someone had told me three years ago before I spent a sprint chasing ghosts.

RxJS streams that use catchError don't throw to the global error handler. They're "handled" — even if your handler just returns EMPTY and moves on.

The checkout bug I mentioned? It lived in an effect() that looked like this:

// This error never reaches the ErrorHandler
loadCoupons = effect(() => {
  this.couponService.validate(this.couponCode()).pipe(
    catchError((err) => {
      console.log("Coupon validation failed:", err);
      return EMPTY; // swallowed silently
    }),
  ).subscribe();
});

The catchError handled it. Angular never saw it. JustAnalytics never saw it. Users just saw a spinner.

The fix is to explicitly track errors in your catchError blocks:

import { catchError, EMPTY } from "rxjs";

loadCoupons = effect(() => {
  this.couponService.validate(this.couponCode()).pipe(
    catchError((err) => {
      // Track before swallowing
      if (window.ja) {
        window.ja("error", err, {
          context: "coupon_validation",
          coupon_code: this.couponCode(),
        });
      }
      this.errorMessage.set("Coupon validation failed");
      return EMPTY;
    }),
  ).subscribe();
});

For teams with a lot of streams, create a utility operator:

// src/app/core/operators/track-error.ts
import { Observable, throwError } from "rxjs";
import { catchError } from "rxjs/operators";

export function trackError<T>(context: string) {
  return (source: Observable<T>): Observable<T> =>
    source.pipe(
      catchError((err) => {
        if (typeof window !== "undefined" && window.ja) {
          window.ja("error", err, { context });
        }
        return throwError(() => err); // rethrow so downstream can still handle
      }),
    );
}

Usage:

this.userService.getProfile().pipe(
  trackError("user_profile_load"),
  catchError(() => of(DEFAULT_PROFILE)), // your actual recovery
).subscribe();

The error gets tracked, then rethrown, then caught by your application-level handler. You get visibility without changing your error recovery logic.

Step 4: Track Route Changes as Pageviews

Angular's router doesn't trigger popstate events the way vanilla SPAs do. For pageviews, you need to subscribe to router events.

Create src/app/core/analytics.service.ts:

import { Injectable } from "@angular/core";
import { Router, NavigationEnd } from "@angular/router";
import { filter } from "rxjs/operators";

@Injectable({ providedIn: "root" })
export class AnalyticsService {
  constructor(private router: Router) {}

  init(): void {
    this.router.events
      .pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd))
      .subscribe((event) => {
        if (typeof window !== "undefined" && window.ja) {
          window.ja("pageview", { url: event.urlAfterRedirects });
        }
      });
  }
}

Initialize it in your app component:

// app.component.ts
import { Component, inject, OnInit } from "@angular/core";
import { AnalyticsService } from "./core/analytics.service";

@Component({
  selector: "app-root",
  template: `<router-outlet></router-outlet>`,
})
export class AppComponent implements OnInit {
  private analytics = inject(AnalyticsService);

  ngOnInit(): void {
    this.analytics.init();
  }
}

Now every route change fires a pageview with the correct URL. Soft navigations, lazy-loaded modules, guards that redirect — all tracked. I'll admit I was skeptical it'd be this simple. Angular routing has burned me before.

Step 5: Tie It Together — Session Context

Here's where JustAnalytics differs from running Sentry plus a separate analytics tool. Every error and pageview shares the same session ID. When someone drops off your checkout funnel, you filter by "sessions with errors" and immediately see which exceptions correlate with abandonment.

The correlation happens automatically — you don't need to pass session IDs around. The script generates one on first load and attaches it to every event.

For teams migrating from GA4 to JustAnalytics, this is the biggest difference. GA4 and Sentry have separate session models that don't talk to each other. Correlating them requires BigQuery exports and SQL joins — and if you've ever written those joins at 2am during an incident, you know they're not fun. With unified observability, you just click a filter.

Common Errors and How to Fix Them

ERROR Error: NG0200: Circular dependency

If you inject Router directly in the ErrorHandler constructor, Angular throws a circular dependency error during bootstrap. Use Injector and resolve the router lazily (as shown in the example above). The router isn't available until after the app initializes anyway.

Zone.js has detected that ZoneAwarePromise (window|global).Promise has been overwritten

Some polyfills and third-party scripts replace window.Promise after Zone.js patches it. This warning is mostly harmless for error tracking, but it can cause unhandled rejections to miss the Zone.js wrapper. If you're seeing errors that don't reach your ErrorHandler, check for polyfill load order issues.

RxJS errors showing as [object Object]

You're passing an HttpErrorResponse or custom error object to the tracking call without unwrapping it. Either extract the message: window.ja('error', err.message || err) — or make sure your error objects have a sensible toString() method.

Pageviews firing twice on initial load

You left data-auto-pageview enabled (or didn't set it to "false"). The script fires one pageview automatically, and your router subscription fires another for the initial NavigationEnd. Disable auto-pageview when using Angular's router.

Errors not appearing in production but working in dev

Check that your environment.production.ts isn't disabling error tracking. Also verify the script is loading — ad blockers sometimes flag analytics domains. You can proxy the script through your own domain (the JustAnalytics docs have a Nginx config for this) to avoid that. This one bit me for longer than I'd like to admit. Tested in Chrome without extensions, worked perfectly. Deployed to prod, radio silence. Turned out half our beta users run uBlock Origin.

Next Steps

With this setup, you've got the foundation for real Angular observability. A few things to add next:

Track custom events for conversion funnels. Add window.ja('event', 'checkout_started', { cart_value: 149.99 }) calls at key points in your user flow. Then correlate error rates by funnel step — I wrote about the exact query for finding conversion-killing bugs.

Enable session replay for the bugs that need visual context. JustAnalytics includes DOM replay with PII masking enabled by default. It's opt-in per-session and adds about 1KB to the initial script size. We've used it to debug race conditions that stack traces alone couldn't explain. Honestly? I think every production app should have replay enabled. The storage cost is trivial compared to the debugging time you save.

Add uptime monitoring for your backend APIs. The same JustAnalytics account includes HTTP checks, SSL monitoring, and alert routing. One script, one dashboard, one bill — instead of stitching together Sentry, GA4, Pingdom, and LogRocket. For teams doing Rails backend integration, the approach is similar.

If you're running multiple Angular apps or need to isolate errors by environment, check out JustBrowser for testing your tracking across isolated browser profiles before deploying. And for teams protecting paid traffic, ClickzProtect catches click fraud while JustAnalytics catches application errors — the two surfaces complement each other when you're debugging why conversions tanked.

The coupon bug I mentioned at the start? Fixed it in under an hour once we had visibility. Three weeks of silent drop-offs because an RxJS stream swallowed an error. That's the cost of not tracking Angular errors properly.

Don't pay it. I did, and I'm still a little mad about it.

Frequently Asked Questions

Does JustAnalytics work with Angular's standalone components?

Yes. Standalone components (Angular 14+) work identically to NgModule-based apps. You provide the custom ErrorHandler in your bootstrapApplication() call instead of an NgModule. The tracking code is the same — JustAnalytics doesn't care whether you're using standalone or NgModule architecture.

How do I capture errors from RxJS streams that use catchError?

When you handle errors with catchError, they don't propagate to the global ErrorHandler. You need to explicitly track them: inside catchError, call window.ja('error', error) before returning your fallback observable. The same applies to any operator that swallows errors like retry() with no rethrow.

Will this slow down my Angular app?

The JustAnalytics script is under 5KB gzipped. It loads async and doesn't block rendering. We've benchmarked it on Angular apps with 50+ components and lazy-loaded routes — the impact on Time to Interactive is under 10ms. For comparison, Sentry's Angular SDK adds 28KB to your bundle.

How do I correlate errors with specific routes or user actions?

JustAnalytics automatically associates errors with the current route via the session context. When you track route changes with router.events, the error and the pageview share the same session ID. You can filter errors by route in the dashboard without any extra code.


Try JustAnalytics

All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).

Start free → · AI Command Center MCP

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts