Tracking Web Vitals in Angular Applications

Angular adds two complications to an otherwise standard reporter: change detection, which will run on every performance callback unless you tell it not to, and a router whose events are the only reliable signal of where the user actually is. This guide sits under Framework Performance Instrumentation and covers a root-provided service that registers observers once, keeps them outside the zone, and attaches the matched route configuration rather than the URL.

Prerequisites

  • An Angular application (standalone bootstrap or NgModule), with the router in use.
  • The web-vitals package, or your own PerformanceObserver setup.
  • A collector endpoint accepting a JSON beacon — the shape is described in Self-Hosted Beacon Collection.
  • Awareness that router navigation does not reset document-scoped metrics, as explained in Soft Navigations & SPA Metrics.

How to instrument it

Step 1 — Provide the reporter once, at the root

// vitals.service.ts — root-provided, so it is instantiated exactly once.
import { Injectable, NgZone, inject } from '@angular/core';
import { onCLS, onFCP, onINP, onLCP, onTTFB, Metric } from 'web-vitals';

@Injectable({ providedIn: 'root' })
export class VitalsService {
  private zone = inject(NgZone);
  private queue: Record<string, unknown>[] = [];
  private route = '/';
  private started = false;

  start(): void {
    if (this.started || typeof window === 'undefined') return;
    this.started = true;

    // Observers fire outside Angular so a metric callback never triggers a
    // change-detection pass. Reporting is a side effect, not a view update.
    this.zone.runOutsideAngular(() => {
      const push = (metric: Metric) => this.push(metric);
      onLCP(push);
      onCLS(push);
      onINP(push);
      onFCP(push);
      onTTFB(push);

      addEventListener('visibilitychange', () => {
        if (document.visibilityState === 'hidden') this.flush();
      });
    });
  }

  setRoute(route: string): void { this.route = route; }

  private push(metric: Metric): void {
    this.queue.push({
      metric: metric.name,
      value: Math.round(metric.value * 1000) / 1000,
      rating: metric.rating,
      navType: metric.navigationType,
      route: this.route,
      ts: Date.now(),
    });
  }

  private flush(): void {
    if (!this.queue.length) return;
    navigator.sendBeacon('/rum', JSON.stringify({ beacons: this.queue.splice(0) }));
  }
}

runOutsideAngular is the load-bearing detail. Without it, every metric callback and every observer entry schedules change detection, which on a large component tree is a measurable cost — and it is a cost incurred by the tool that is supposed to be measuring cost.

Step 2 — Stamp the matched route, not the URL

Angular’s router exposes the configured path of the matched route, which is the low-cardinality key you want. Walk the activated-route tree at the end of each navigation.

// app.component.ts — bootstrap the service and keep the route current.
import { Component, inject, OnInit } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs/operators';
import { VitalsService } from './vitals.service';

@Component({ selector: 'app-root', template: '<router-outlet />', standalone: true })
export class AppComponent implements OnInit {
  private router = inject(Router);
  private activated = inject(ActivatedRoute);
  private vitals = inject(VitalsService);

  ngOnInit(): void {
    this.vitals.start();
    this.router.events
      .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
      .subscribe(() => this.vitals.setRoute(this.matchedPath()));
  }

  private matchedPath(): string {
    // Build '/products/:id' from the route config, not '/products/8841'.
    let route = this.activated.root;
    const parts: string[] = [];
    while (route.firstChild) {
      route = route.firstChild;
      const path = route.routeConfig?.path;
      if (path) parts.push(path);
    }
    return '/' + parts.join('/');
  }
}

Step 3 — Measure the navigations the router performs

NavigationStart and NavigationEnd bracket the router’s work, but the number a user experiences includes the paint that follows. Measure to the second animation frame after the navigation ends.

private timeNavigations(): void {
  let start = 0;
  this.router.events.subscribe((event) => {
    if (event instanceof NavigationStart) { start = performance.now(); return; }
    if (!(event instanceof NavigationEnd) || !start) return;
    const from = start;
    start = 0;
    this.zone.runOutsideAngular(() => {
      requestAnimationFrame(() => requestAnimationFrame(() => {
        this.report('route_change', performance.now() - from, {
          to: this.matchedPath(),
          // Lazy-loaded modules make the first visit to a route much slower.
          firstVisit: !this.seen.has(this.matchedPath()),
        });
        this.seen.add(this.matchedPath());
      }));
    });
  });
}

The firstVisit flag is worth carrying in any application using lazy-loaded routes: the first navigation to a route includes the chunk download, and every subsequent one does not. Without the flag the distribution is bimodal and the p75 sits in the gap between the two modes.

p75 route-change duration, first visit against repeatRoute resolvers hold the navigation until data arrives, so the chunk download and the API call are serialised. Preloading the chunk removes half of it.p75 route-change duration, first visit against repeatLazy-loaded feature routes, mid-tier deviceRepeat visit, cached chunk210 msFirst visit, small chunk640 msFirst visit, large feature module1,480 msFirst visit, with resolver2,140 ms0 ms625 ms1,250 ms1,875 ms2,500 ms
Route resolvers hold the navigation until data arrives, so the chunk download and the API call are serialised. Preloading the chunk removes half of it.

Step 4 — Choose a preloading strategy deliberately

Angular ships PreloadAllModules, which downloads every lazy chunk after the initial navigation. It removes the first-visit penalty at the cost of bandwidth that may never be used — an easy win on desktop, a poor trade on a metered mobile connection.

// A network-aware strategy: preload only when the connection can afford it.
export class AdaptivePreloadStrategy implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
    const conn = (navigator as any).connection;
    const cheap = !conn || (!conn.saveData && /4g/.test(conn.effectiveType ?? '4g'));
    return cheap && route.data?.['preload'] !== false ? load() : of(null);
  }
}

The signals this reads, and their caveats, are covered in Segmenting RUM Data by Effective Connection Type.

Step 5 — Watch what change detection costs during interactions

Angular applications rarely have an LCP problem and frequently have an INP one, because a single click can trigger a change-detection pass over the whole component tree. The measurement that localises it is Long Animation Frames, attributing script time to the framework’s tick.

// Report the frames where change detection dominated the interaction.
if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
  new PerformanceObserver((list) => {
    for (const frame of list.getEntries() as any[]) {
      if (frame.duration < 100) continue;
      const dominant = (frame.scripts ?? [])
        .sort((a: any, b: any) => b.duration - a.duration)[0];
      this.report('loaf', frame.duration, {
        invoker: dominant?.invoker ?? 'unknown',
        source: dominant?.sourceURL ?? 'unknown',
        renderTime: Math.round(frame.renderDuration ?? 0),
      });
    }
  }).observe({ type: 'long-animation-frame', buffered: true } as any);
}

A high renderDuration relative to script time points at change detection and template work rather than at your handler. OnPush on the components involved, or signals in newer Angular versions, is the structural fix; the interaction-level fixes are in INP Tracking & Debugging.

Step 6 — Keep server-side rendering out of the way

Angular Universal executes the same code on the server, where window, document and performance do not exist in the shape the browser provides. Guard the service with a platform check rather than a typeof test so the intent is explicit.

import { PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

private isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
// ...
start(): void { if (!this.isBrowser || this.started) return; /* … */ }
Where an Angular interaction spends its timeThe handler is the same in all three rows. Everything that moved is framework work, which is why optimising the handler first rarely helps.Where an Angular interaction spends its timeLong Animation Frame composition on a data-heavy screenDefault change detection10%55%23%12%OnPush on the affected tree18%33%30%20%Signals, no zone24%16%34%26%HandlerChange detectionStyle and layoutPaint
The handler is the same in all three rows. Everything that moved is framework work, which is why optimising the handler first rarely helps.

Verifying it works

  1. Beacon coverage above about 95% of sessions, measured at the collector. A shortfall means the service is being provided per-module rather than at the root, or the flush is not firing.
  2. Route values contain : placeholders for parameterised routes. A raw URL means matchedPath is reading the wrong tree.
  3. No change-detection storm during measurement. Profile a page with the reporter active and confirm the callbacks are not scheduling ticks.
  4. firstVisit splits the route-change distribution cleanly into two modes. If it does not, preloading is already flattening it.
  5. INP attribution names your handlers, not the framework tick. If it names the tick, the work is in change detection.

What to expect in the first week of data

Angular applications tend to produce a recognisable field profile, and knowing it in advance shortens the first investigation considerably.

LCP is usually acceptable and TTFB-bound. Server-rendered Angular delivers markup quickly and the largest element is often text or an image already in that markup. Where LCP is bad it is nearly always the server response, not the framework.

INP is the metric that fails, and it fails on the busiest screens. Forms, tables and dashboards with many bound expressions are where change detection costs the most, and they are exactly the screens with the most interactions. Rank routes by their own worst interaction rather than by session INP.

The first navigation to each lazy route is the outlier. Until preloading is configured, the route-change distribution has two modes separated by the chunk download. Any percentile computed across both is a number in the gap between them.

Session depth matters more than in most frameworks. Long-lived Angular sessions accumulate subscriptions, and an undisposed Subscription keeps its component graph alive. Teardown discipline — takeUntilDestroyed, or an explicit ngOnDestroy — is a performance practice as much as a memory one, and its effect shows up as late-session INP.

Four fields on the beacon give you all four of those cuts: matched route, first-visit flag, session depth and render duration. None of them is expensive to collect, and together they answer most of the questions the raw p75 raises.

p75 INP by screen complexityInteraction latency tracks the size of the tree change detection has to walk, which is why OnPush and signals move this number more than handler work does.p75 INP by screen complexityBound expressions in the active component tree against interaction latencySimple form (under 200 bindings)96 msProduct detail (about 900)168 msData table (about 3,400)392 msDashboard (about 7,000)610 ms0 ms250 ms500 ms750 ms1,000 msGood 200 ms
Interaction latency tracks the size of the tree change detection has to walk, which is why OnPush and signals move this number more than handler work does.

Edge cases and gotchas

  • Providing the service in a feature module creates one instance per module, so observers register several times and metrics are duplicated. providedIn: 'root' is not optional here.
  • Router events fire before paint. NavigationEnd means routing finished, not that anything is on screen. Always wait two animation frames.
  • NavigationCancel and NavigationError leave a dangling start timestamp. Reset it on those events or the next navigation reports an inflated duration.
  • Zone.js patches everything. requestAnimationFrame inside the zone schedules change detection; keep timing code inside runOutsideAngular.
  • Zoneless applications behave differently. With provideExperimentalZonelessChangeDetection, runOutsideAngular becomes a no-op and the callbacks are already outside — harmless, but the reasoning changes.
  • Hash-based routing produces URLs the collector may normalise oddly. Stamp the matched config, which is independent of the location strategy.

FAQ

Do I need runOutsideAngular if my app is zoneless?

No, but leaving it in is harmless and keeps the service correct in both modes. In a zone-based application it is essential: without it every metric callback schedules a change-detection pass.

Why is my route showing as the full URL?

Because the reporter is reading router.url rather than walking the activated-route tree for routeConfig.path. The URL contains parameter values, which explodes cardinality and makes per-route percentiles impossible.

Should I use PreloadAllModules?

On desktop-heavy traffic, usually yes. On mobile-heavy or international traffic, a network-aware strategy is better — preloading every chunk on a metered connection spends the user’s data on routes they may never open.

My LCP is fine but INP is poor. Where do I look?

At change detection. A single interaction that triggers a full tree check is the most common cause in Angular applications, and Long Animation Frames with a high render duration is the signature.