Framework Performance Instrumentation
In a single-page application the framework is not a passive host for your metrics — it actively reshapes them. Soft navigations keep one document alive across many “pages”, so INP and CLS accumulate across routes the user perceives as separate, while hydration runs a burst of main-thread work that lands squarely on LCP and the first interaction. As established in Core Web Vitals & Performance Metrics Fundamentals, field data is the only honest source of truth, and the field is full of frameworks. This page covers where to mount the web-vitals library so it survives client-side routing, how hydration and streaming distort the load-phase metrics, and the per-framework specifics for Next.js, React, Vue/Nuxt, SvelteKit, and Angular — with the failure modes that silently corrupt your numbers when the instrumentation outlives a component but not the page.
Why the framework shapes the metric
Core Web Vitals were specified against the document lifecycle: LCP measures the largest paint before the first interaction, CLS sums layout shifts until the page is unloaded, and INP reports the worst interaction across the document’s life. A multi-page-application maps one document to one URL, so those definitions line up with what the user calls a “page”. A SPA breaks that mapping. The framework router intercepts link clicks, swaps the view in place, and updates the URL with the History API — but the document never unloads. To the metrics, every route the user visits is part of the same page view.
That single fact produces three distinct problems. LCP is captured only during the initial load, so route changes after first paint have no LCP at all unless you opt into the Soft Navigations experiment. CLS and INP, by contrast, never stop: a layout shift on route three is attributed to the same page view as route one, and the worst interaction anywhere in the session becomes the page’s INP. Hydration adds a fourth: the server ships HTML that paints quickly (good for LCP’s render), then the framework attaches event listeners and rebuilds component state, flooding the main thread with a long task that delays interactivity and can push the LCP element later if it mutates the DOM. Streaming and React Server Components fragment this further, interleaving server-rendered chunks with client work so the timeline no longer has a clean “load finished” boundary.
Framework concern → metric reference
Map each framework behaviour to the metric it distorts before you write any instrumentation. The fix is almost always to make the reporter aware of the lifecycle the framework actually has, not the document lifecycle the metric assumes.
| Framework concern | Metric primarily affected | What goes wrong | Engineering action |
|---|---|---|---|
| Soft navigation (client routing) | INP, CLS | Metrics accumulate across routes into one page view | Reset/segment per route via router hook |
| Hydration burst | LCP, INP | Long task delays first paint and first interaction | Defer/island hydration; measure hydration cost |
| SSR streaming / RSC | LCP, CLS | Late chunks shift layout; LCP element re-evaluated | Reserve space; pin LCP candidate above the fold |
| Component-mounted reporter | All | Listener torn down on unmount; metrics lost | Mount once at app root, never inside a route component |
| Double app init (HMR, dual roots) | INP | onINP registered twice; duplicate beacons |
Guard init with a module-level singleton |
| bfcache restore | LCP, INP | Restored page never re-reports; numbers stale | Listen for pageshow/persisted and re-init |
The recurring theme is lifecycle mismatch: the metric assumes a document, the framework gives you a router. Every row below ties back to fixing that mismatch in code.
Where to mount the reporter
The reporter must outlive every route and mount exactly once. The fatal anti-pattern is calling onLCP/onINP/onCLS inside a route component’s useEffect or onMounted — that registers a fresh observer on every navigation and tears the listener down on unmount, so you double-count, leak observers, and lose the finalising flush. Mount at the application root, above the router, in a module that runs once per document.
// vitals.js — imported once from the app entry, never from a route component.
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals/attribution';
let started = false; // module-level singleton guards against double init.
export function startVitals(report) {
if (started) return;
started = true;
const opts = { reportAllChanges: false };
onLCP(report, opts);
onFCP(report, opts);
onTTFB(report, opts);
// INP and CLS report their final value on visibility change; web-vitals
// handles the visibilitychange/pagehide finalisation internally.
onINP(report, opts);
onCLS(report, opts);
}
The started flag is not optional defensiveness — Hot Module Replacement in dev, dual React roots in micro-frontends, and accidental double-import in an SSR bundle all call the entry twice. Without the guard you register two onINP observers, each emits a beacon, and your ingestion endpoint sees doubled volume that quietly biases percentiles. The reporter callback itself is framework-agnostic; it batches and finalises exactly as the web-vitals API implementation cluster describes, using navigator.sendBeacon on visibilitychange.
A framework-agnostic reporter plus router hook
The portable pattern is two pieces: a reporter that attaches a stable pageId and the current route to every metric, and a router hook that rotates that pageId on each soft navigation. The reporter never changes between frameworks; only the router hook is framework-specific, and it is three lines.
// reporter.js — framework-agnostic. Stamps route + soft-nav id on every metric.
const ENDPOINT = '/api/v1/metrics';
let currentRoute = location.pathname;
let pageId = crypto.randomUUID(); // rotated on each soft navigation.
let buffer = [];
export function setRoute(path) {
currentRoute = path;
pageId = crypto.randomUUID(); // new logical page view starts here.
}
function flush() {
if (buffer.length === 0) return;
navigator.sendBeacon(ENDPOINT, JSON.stringify({ metrics: buffer }));
buffer = [];
}
export function report(metric) {
if (navigator.webdriver) return; // drop synthetic/bot traffic.
buffer.push({
name: metric.name, // 'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB'
value: metric.value,
rating: metric.rating,
nav_type: metric.navigationType, // 'navigate' | 'back-forward' | ...
route: currentRoute, // per-route segmentation key
page_id: pageId, // groups metrics of one soft-nav view
framework_version: __APP_VERSION__, // injected at build; see CI gating
});
}
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
addEventListener('pagehide', flush);
The router hook calls setRoute after the framework commits the navigation. Below are the one-liners for the major routers; each fires once the new route is the active one.
// Next.js App Router — usePathname in a client component at the layout root.
'use client';
import { usePathname } from 'next/navigation';
import { useEffect } from 'react';
import { setRoute } from './reporter';
export function RouteWatcher() {
const pathname = usePathname();
useEffect(() => { setRoute(pathname); }, [pathname]);
return null;
}
// Vue Router (Vue/Nuxt) — afterEach fires post-commit.
router.afterEach((to) => setRoute(to.fullPath));
// SvelteKit — subscribe to the page store once at the root layout.
import { afterNavigate } from '$app/navigation';
afterNavigate(({ to }) => { if (to) setRoute(to.url.pathname); });
// Angular Router — filter NavigationEnd events.
import { Router, NavigationEnd } from '@angular/router';
router.events.subscribe((e) => {
if (e instanceof NavigationEnd) setRoute(e.urlAfterRedirects);
});
Because LCP only fires on the hard load, rotating pageId on soft navigation gives INP and CLS a clean per-route grouping without pretending soft navigations produce an LCP. If you need soft-navigation LCP, opt into the experimental Soft Navigations API (onLCP(report, { reportSoftNavs: true }) where supported) — but treat it as supplementary, since field coverage is still Chromium-experimental and absent in most browsers.
Per-framework specifics
Next.js
The App Router renders Server Components on the server and streams them, then hydrates Client Components. Mount the reporter in the root app/layout.tsx via a 'use client' component so it runs once; never put it in a page.tsx, which remounts on navigation. Next exposes useReportWebVitals from next/web-vitals, which is a thin wrapper over the library — it is fine, but it still needs to live at the layout root, and you must add the RouteWatcher above for per-route segmentation. Streaming Suspense boundaries are the main CLS hazard: a boundary that resolves and injects content above the fold shifts everything below it. Reserve space for streamed slots. Attributing slow interactions to hydration and the server/client boundary is involved enough to warrant its own walkthrough — see Instrumenting INP in the Next.js App Router.
React (and Vite/CRA SPAs)
Plain React SPAs hydrate (or client-render) once at createRoot/hydrateRoot. The hydration pass is a single long task whose cost scales with component count and is the dominant input-delay source for any interaction that lands during settle. Because the LCP element often paints from server HTML before hydration, hydration’s effect on LCP is indirect: if a useEffect mutates the hero or a layout-affecting state change runs on mount, the LCP candidate can be re-evaluated later. Measuring that interaction precisely is its own task — see Measuring React Hydration’s Impact on LCP. The instrumentation rule is unchanged: startVitals in main.tsx outside the component tree, and route segmentation through the React Router hook.
Vue and Nuxt
Vue’s PerformanceObserver wiring follows the same root-mount rule, but Nuxt adds an SSR/hydration split: server-rendered markup hydrates on the client, and nuxtApp plugins run on both sides. Register the reporter in a client-only Nuxt plugin (plugins/vitals.client.ts) so it never executes during SSR, where PerformanceObserver and navigator.sendBeacon do not exist and would throw. Vue Router’s afterEach gives clean per-route pageId rotation. The full observer setup, the client-only plugin boundary, and Nuxt’s hydration timing are covered in Vue & Nuxt PerformanceObserver Setup.
SvelteKit
SvelteKit hydrates the server-rendered page and uses afterNavigate for client routing. Put startVitals in the root +layout.svelte’s onMount (which runs only in the browser, so it is SSR-safe) and rotate the route with afterNavigate. SvelteKit’s smaller hydration footprint usually means a shorter hydration long task than React, but its data loaders can stream, so the same Suspense-style CLS caution applies to late-resolving await blocks.
Angular
Angular bootstraps once via bootstrapApplication; mount the reporter in an APP_INITIALIZER or the root component’s ngOnInit, not in a feature component. Angular’s zone.js historically wrapped every async task, which can mask the true cost of change detection in your traces; with zoneless Angular the picture is cleaner. Use NavigationEnd from the Router for pageId rotation. Hydration via provideClientHydration() follows the React pattern: server HTML paints, then hydration attaches listeners as one long task.
| Framework | Mount point | Route hook | SSR/hydration note |
|---|---|---|---|
| Next.js App Router | app/layout.tsx client component |
usePathname effect |
RSC streaming; reserve Suspense slot space |
| React SPA | main.tsx outside tree |
React Router hook | Single hydrate pass = one long task |
| Vue/Nuxt | plugins/vitals.client.ts |
router.afterEach |
Client-only plugin; no PerformanceObserver in SSR |
| SvelteKit | root +layout.svelte onMount |
afterNavigate |
onMount is browser-only, SSR-safe |
| Angular | APP_INITIALIZER |
NavigationEnd |
provideClientHydration; watch zone.js noise |
Debugging workflow
When a route’s field vitals regress, the framework lifecycle is the first suspect. Run this loop rather than guessing whether it is “the framework” or “your code”.
- Identify the route and metric. Group field data by the
routekey and rank each metric’s p75 per route. A single route regressing points at that route’s code; every route regressing points at the shared shell or the reporter itself (often double init). - Confirm the page-view boundary. Verify
page_idrotates on each soft navigation by checking that consecutive routes carry distinct ids. If onepage_idspans many routes, your router hook is not firing and INP/CLS are accumulating — fix segmentation before reading any numbers. - Trace the waterfall in the lab. Record a Performance trace through the navigation. Look for the hydration long task and the framework’s commit phase; the Long Animation Frames data in the INP attribution payload names the script and source location.
- Correlate overlaps. Check whether the slow interaction or shift overlaps hydration or a streamed chunk landing. Interactions during settle inherit delay from hydration; this is the overlap with LCP Measurement & Optimization work.
- Validate the fix in the lab. Confirm the trace shows the hydration task broken up (islands, deferred hydration) or the layout space reserved, then re-record to verify the metric moves.
- Deploy and monitor the delta. Ship behind a flag and watch the per-route p75 delta in field data, segmented by
framework_version, over the following days. A lab win that does not move the field p75 means you fixed the wrong route or device tier.
Field-data analysis patterns
Framework instrumentation lives or dies on segmentation, because an aggregate p75 hides exactly the route- and version-level divergences you need. Always split before drawing conclusions.
| Segment axis | What divergence to watch | Likely cause |
|---|---|---|
Route (route key) |
One route’s INP/CLS far above siblings | Heavy route component or unreserved streamed slot |
| Framework version | p75 jumps at a deploy boundary | Framework upgrade changed hydration or commit cost |
| Navigation type | navigate worse than back-forward |
bfcache skips hydration; cold routes pay full cost |
| Device class | Low-tier mobile hydration 3–4× desktop | Hydration long task saturates a slow main thread |
| Soft vs hard nav | Hard-load LCP fine, soft-nav INP poor | Per-route work landing after first paint |
The divergence that catches teams out is the framework_version jump: a minor framework upgrade quietly changes the hydration strategy, p75 INP steps up across every route at the deploy boundary, and without versioned segmentation it looks like organic drift. Read every headline at p75 of the sampled population, never a mean.
Failure modes and gotchas
Framework instrumentation fails silently far more often than it throws. These produce wrong numbers, not stack traces.
- Double init. Registering
onINP/onLCPtwice — via HMR, dual roots, or a reporter imported from both server and client bundles — emits duplicate beacons and inflates volume. The module-levelstartedsingleton is the fix; without it, percentiles are quietly biased. - Listener torn down on unmount. Mounting the reporter inside a route component means every navigation unmounts the observer and remounts a new one, losing the finalising flush and leaking observers. Mount once at the app root, above the router.
- Soft nav not resetting per-load metrics. If you do not rotate
page_idon navigation, INP and CLS accumulate across every route in the session and the worst route poisons all the others. Conversely, do not “reset” LCP on soft nav — LCP has no soft-nav value by default, and faking one produces garbage. - SSR calling browser APIs.
PerformanceObserver,navigator.sendBeacon, andcrypto.randomUUIDdo not exist during server rendering. A reporter imported into the SSR path throws and can break the render. Gate it behind a browser check or a client-only entry (Nuxt.client, SvelteKitonMount, Next'use client'). - bfcache restores. A page restored from bfcache never re-runs its load, so it never re-reports. Listen for
pageshowwithevent.persistedand treat the restore as a new page view, or you systematically under-report fast back-forward navigations. - Safari and Firefox gaps. INP’s Event Timing backing is Chromium-only at scale; missing INP on those browsers is unmeasured, not fast. Weight your traffic mix accordingly.
CI/CD integration
Gate framework regressions two ways. In the lab, drive the navigation with Playwright and assert the hydration long task and Total Blocking Time stay under budget — TBT is the best lab proxy for hydration-driven INP. In the field, gate on the per-route, per-version delta so a framework upgrade that inflates hydration cannot ship unnoticed. Inject the framework version into the beacon at build time so the field gate can attribute the delta.
# Inject the app/framework version the reporter stamps onto every beacon.
export APP_VERSION="$(node -p "require('./package.json').version")-$GIT_SHA"
# Lab gate: scripted soft navigation; assert hydration long task < budget.
npx playwright test framework-vitals.spec.ts
# Field gate: per-route p75 delta against the previous build's baseline.
node ./ci/check-framework-regression.js \
--build "$APP_VERSION" --baseline-days 7 --per-route --max-delta-ms 40
Pair the lab proxy with the versioned field delta and you catch both the obvious regression (a new synchronous effect on a hot route) before merge and the subtle one (a framework upgrade that lengthens hydration only on low-tier mobile) within a day of release.
FAQ
Where should I mount web-vitals in a SPA?
At the application root, in a module that runs once per document and outlives the router — never inside a route component’s useEffect or onMounted. Mounting inside a route component registers a fresh observer on every navigation and tears it down on unmount, which double-counts metrics and loses the finalising flush. Guard the init with a module-level singleton flag to survive HMR and dual roots.
Why does my SPA report one inflated INP across every route?
Because the document never unloads, INP and CLS accumulate across all soft-navigated routes into a single page view, so the worst interaction anywhere becomes the whole session’s INP. Rotate a per-route page_id on each navigation via your router hook (afterEach, usePathname, afterNavigate, or NavigationEnd) so each route is grouped and segmented separately.
Does hydration affect LCP or only INP?
It affects both, but differently. Hydration is a long task that delays interactivity, so it primarily inflates the input-delay phase of INP for any interaction during settle. Its LCP effect is indirect: server HTML usually paints the LCP element before hydration, but if a mount effect mutates the hero or triggers a layout-affecting state change, the LCP candidate can be re-evaluated later.
How do I get LCP for soft navigations?
By default you do not — LCP fires only on the hard document load. The experimental Soft Navigations API exposes soft-nav LCP via onLCP(report, { reportSoftNavs: true }) in recent Chromium, but field coverage is experimental and absent elsewhere. Treat soft-nav LCP as supplementary and keep your hard-load LCP as the headline.
Why does my reporter throw during server-side rendering?
PerformanceObserver, navigator.sendBeacon, and crypto.randomUUID only exist in the browser. If the reporter is imported into the SSR path it runs server-side and throws, sometimes breaking the render. Move it behind a client-only boundary: a Nuxt .client plugin, SvelteKit onMount, a Next 'use client' component, or an explicit typeof window guard.
Related
- Instrumenting INP in the Next.js App Router — attributing slow interactions to hydration and server-component boundaries.
- Measuring React Hydration’s Impact on LCP — isolating the hydration long task’s effect on the largest paint.
- Vue & Nuxt PerformanceObserver Setup — client-only plugin wiring and SSR-safe observer mounting.
- INP Tracking & Debugging — the event-timing capture and phase attribution behind soft-nav INP.
- Web Vitals API Implementation — PerformanceObserver setup and the attribution build the reporter wraps.