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-vitalspackage, or your ownPerformanceObserversetup. - 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.
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; /* … */ }
Verifying it works
- 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.
- Route values contain
:placeholders for parameterised routes. A raw URL meansmatchedPathis reading the wrong tree. - No change-detection storm during measurement. Profile a page with the reporter active and confirm the callbacks are not scheduling ticks.
firstVisitsplits the route-change distribution cleanly into two modes. If it does not, preloading is already flattening it.- 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.
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.
NavigationEndmeans routing finished, not that anything is on screen. Always wait two animation frames. NavigationCancelandNavigationErrorleave a dangling start timestamp. Reset it on those events or the next navigation reports an inflated duration.- Zone.js patches everything.
requestAnimationFrameinside the zone schedules change detection; keep timing code insiderunOutsideAngular. - Zoneless applications behave differently. With
provideExperimentalZonelessChangeDetection,runOutsideAngularbecomes 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.
Related
- Framework Performance Instrumentation — the mounting rules and the metric risks per framework concern.
- INP Tracking & Debugging — fixing the interaction latency this instrumentation exposes.
- Instrumenting Core Web Vitals in SvelteKit — the same problem in a framework with a different navigation model.