Vue & Nuxt PerformanceObserver Setup
A Vue or Nuxt SPA mounts once and then lives for the whole session, swapping views through the router without a full document load. That breaks the naive instinct to drop a <script> of PerformanceObserver code into a page component: it would re-register on every route, double-count, and leak observers. The correct pattern — covered here as a piece of Framework Performance Instrumentation — is to register the web-vitals reporting exactly once, during app bootstrap, and let it manage its own lifecycle. This page shows how to do that with a Vue plugin in main.js and a Nuxt 3 client plugin in plugins/web-vitals.client.ts, reporting Largest Contentful Paint (LCP), Interaction to Next Paint (INP), Cumulative Layout Shift (CLS), and First Contentful Paint and Time to First Byte, then sending each as a beacon.
Prerequisites
Before wiring anything, confirm these are in place:
| Requirement | Detail |
|---|---|
| Vue / Nuxt version | Vue 3.x (Composition API) or Nuxt 3.x. The plugin pattern is identical; only the registration file differs. |
| web-vitals | npm i web-vitals@^4. Provides onLCP, onINP, onCLS, onFCP, onTTFB built on PerformanceObserver and buffered entries. |
| Router | vue-router@^4 (standalone Vue) or Nuxt’s built-in router. You need access to afterEach to tag routes. |
| RUM endpoint | A POST endpoint that accepts JSON. See self-hosted beacon collection for the receiver. |
| Aggregation | Send raw per-sample values; compute p75 server-side, never in the client. |
The Good thresholds your p75 must clear are fixed by Google: LCP at or below 2.5 s, INP at or below 200 ms, CLS at or below 0.1, FCP at or below 1.8 s, and TTFB at or below 800 ms. Every snippet below reports the raw value so your pipeline can derive p75 per route and per device class.
| Metric | Good (p75) | Needs Improvement | Poor | Resets on SPA navigation? |
|---|---|---|---|---|
| LCP | ≤ 2.5 s | ≤ 4.0 s | > 4.0 s | No — fired once per document load |
| INP | ≤ 200 ms | ≤ 500 ms | > 500 ms | No — accumulates across the session |
| CLS | ≤ 0.1 | ≤ 0.25 | > 0.25 | No — accumulates across the session |
| FCP | ≤ 1.8 s | ≤ 3.0 s | > 3.0 s | No — fired once per document load |
| TTFB | ≤ 800 ms | ≤ 1.8 s | > 1.8 s | No — measured from the initial navigation only |
How to wire web-vitals into Vue and Nuxt
Step 1 — Write the shared reporter
Both targets share one reporter module. It owns the beacon transport and a single report function that all five web-vitals callbacks funnel through. Create src/rum/reporter.js:
const ENDPOINT = '/rum';
// Mutable label updated by the router so each metric carries the route it occurred on.
let currentRoute = typeof location !== 'undefined' ? location.pathname : '/';
export function setRoute(path) {
currentRoute = path;
}
function send(payload) {
const body = JSON.stringify(payload);
// sendBeacon survives unload and SPA route changes; fetch keepalive is the fallback.
if (navigator.sendBeacon && navigator.sendBeacon(ENDPOINT, body)) return;
fetch(ENDPOINT, { body, method: 'POST', keepalive: true }).catch(() => {});
}
// CLS is a unitless ratio; the time-based metrics are milliseconds and should be rounded.
function normalize(name, value) {
return name === 'CLS' ? Math.round(value * 1000) / 1000 : Math.round(value);
}
export function report(metric) {
send({
metric: metric.name, // 'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB'
value: normalize(metric.name, metric.value),
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
id: metric.id, // stable per page load — dedupe on this server-side
navigationType: metric.navigationType, // 'navigate' | 'back-forward' | 'reload'
route: currentRoute,
ts: Date.now(),
});
}
Why: keeping transport and routing state in one module means the Vue and Nuxt entry points stay tiny and identical. setRoute is the only mutable bridge between the router and the metric callbacks — the callbacks never import the router, so there is no circular dependency. Rounding INP and the load metrics to integers and CLS to three decimals keeps payloads compact without losing the precision p75 needs.
Step 2 — Register the observers once
Add a single function that registers all five web-vitals listeners. Each library function attaches its own PerformanceObserver with buffered: true, so entries dispatched before bootstrap are not lost. Append to src/rum/reporter.js:
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
let registered = false;
export function registerWebVitals() {
if (registered) return; // guard against duplicate registration (HMR, StrictMode-like remounts)
registered = true;
// reportAllChanges:false => one finalized value per metric on the lifecycle, matching Google's algorithm.
const opts = { reportAllChanges: false };
onLCP(report, opts);
onINP(report, opts);
onCLS(report, opts);
onFCP(report, opts);
onTTFB(report, opts);
}
Why: the registered guard is the single most important line. Vue plugins install once, but hot-module reload in development, or accidentally calling app.use() twice, would otherwise attach duplicate observers and double your reported volume. reportAllChanges: false makes each metric fire one finalized value when the page is hidden or backgrounded — that is what your p75 should be built from, not intermediate updates.
Step 3 — Register the Vue plugin in main.js
For a standalone Vue 3 app, expose registerWebVitals as a Vue plugin and install it once in main.js, after the router so the route tagging in Step 5 can attach. Create src/rum/plugin.js:
import { registerWebVitals, setRoute } from './reporter';
export const WebVitalsPlugin = {
install(app, { router } = {}) {
registerWebVitals();
if (router) {
// Tag every navigation so per-interaction metrics carry the route they happened on.
router.afterEach((to) => setRoute(to.fullPath));
setRoute(router.currentRoute.value.fullPath);
}
},
};
Then in src/main.js:
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { WebVitalsPlugin } from './rum/plugin';
const app = createApp(App);
app.use(router);
app.use(WebVitalsPlugin, { router }); // install AFTER the router
app.mount('#app');
Why: installing the plugin once in main.js guarantees the observers register exactly when the app boots — never inside a route component, which would re-run on every visit. Passing router lets the plugin hook afterEach without the reporter module importing the router. The buffered: true flag inside each web-vitals listener replays any FCP, LCP, or TTFB entry that already fired during the initial HTML parse, so registering slightly after first paint loses nothing.
Step 4 — Register the Nuxt 3 client plugin
Nuxt has no main.js; instead, files in plugins/ auto-register. The .client.ts suffix is mandatory here because PerformanceObserver and navigator.sendBeacon exist only in the browser — a universal plugin would throw during server-side rendering. Create plugins/web-vitals.client.ts:
import { registerWebVitals, setRoute } from '~/rum/reporter';
export default defineNuxtPlugin((nuxtApp) => {
// Runs once, client-side only, after the app is created.
registerWebVitals();
const router = useRouter();
router.afterEach((to) => setRoute(to.fullPath));
setRoute(router.currentRoute.value.fullPath);
// Optional: also tag the very first server-rendered route before any navigation.
nuxtApp.hook('app:mounted', () => {
setRoute(router.currentRoute.value.fullPath);
});
});
Why: the .client suffix scopes the plugin to the browser bundle so SSR never evaluates browser-only APIs. defineNuxtPlugin runs after the Nuxt app is created and the router is available, so useRouter() resolves cleanly. The app:mounted hook captures the entry route, which matters because Nuxt hydrates a server-rendered page — the initial LCP and FCP belong to that first route, and you want them tagged correctly before the user navigates.
Step 5 — Understand why router navigation does not reset per-load metrics
The afterEach hook tags routes; it deliberately does not restart metric collection. This is the central correctness point, so make it explicit in code with a comment block in your router or plugin:
// vue-router afterEach only updates the ROUTE LABEL via setRoute().
// It does NOT and MUST NOT re-call registerWebVitals(), because:
// - LCP, FCP, TTFB are per-document-load metrics. A soft navigation
// (history.pushState) is not a new document, so the browser never emits
// a fresh LCP/FCP/TTFB entry. Re-registering would only re-attach idle observers.
// - INP and CLS are session-cumulative by spec. They keep aggregating across
// routes and finalize once on pagehide. Resetting them would discard real layout
// shifts and slow interactions that the user actually experienced.
router.afterEach((to) => setRoute(to.fullPath));
Why: SPA frameworks make it tempting to treat each view as a “page,” but the browser’s performance timeline does not. LCP fires once, against the initial paint of the real document; after that, no largest-contentful-paint entry is emitted no matter how many views you render. INP and CLS, by contrast, accumulate for the lifetime of the document and report their worst/total value at the end. The only per-route work that is correct is relabelling — which is exactly what setRoute does, letting you attribute a slow interaction to the view it happened on without falsifying the metric itself.
Step 6 — Flush correctly on page exit
web-vitals already listens for visibilitychange and pagehide to finalize each metric, so the report callbacks fire automatically at the right moment. Verify nothing in your app calls event.preventDefault() on pagehide or registers a beforeunload handler that blocks the beacon — both can suppress the final flush. No extra code is required; the guarantee comes from sendBeacon in Step 1, which is queued by the browser even as the document tears down.
Verifying it works
- Console sanity check. Temporarily wrap
reportwithconsole.debug('[vitals]', metric.name, metric.value, metric.rating). Load the app, then background the tab (switch tabs or close it). You should see exactly one log line per metric — LCP, FCP, TTFB once; INP and CLS once each on hide. More than one per metric means a duplicate registration; check the Step 2 guard. - DevTools Performance panel. Record a trace and read the Timings and Interactions tracks. The LCP marker time and the largest interaction’s duration should match the
valueyour beacon reports for the sameid. - RUM endpoint. Watch the collector receive five
metrictypes. After a soft navigation, confirm INP/CLS beacons carry the later route inroute(because they finalize at exit) while LCP/FCP/TTFB carry the entry route — exactly the behaviour the table in Prerequisites predicts. - p75 in the dashboard. Once samples accumulate, the server-computed p75 per route should sit at or below the Good thresholds. Segment by device class and network type; a Vue SPA’s INP often regresses on low-end mobile where hydration and route-transition JS contend for the main thread.
| Source | What you see | Confirms |
|---|---|---|
Console [vitals] log on tab hide |
One line per metric | Single registration, callbacks finalize |
| Performance panel Timings/Interactions | LCP marker, interaction bars | Reported value matches the real timeline |
RUM beacon route field |
Entry route on load metrics, exit route on INP/CLS | Route tagging and metric lifecycle both correct |
Edge cases & gotchas
- Nuxt universal plugin crashes SSR. Naming the file
web-vitals.tsinstead ofweb-vitals.client.tsmakes Nuxt run it on the server, wherenavigatorandPerformanceObserverare undefined — the render throws. The.clientsuffix is not optional for browser-only instrumentation. - Re-registering on every route doubles your data. The most common Vue mistake is calling the web-vitals functions inside a component’s
onMountedor inside a route guard. Because soft navigations do not emit new per-load entries, this gives you no new LCP data but does attach duplicate INP/CLS observers, inflating volume and skewing p75. Register once; relabel per route. - Safari finalizes on
pagehide, notbeforeunload. Safari historically does not always firevisibilitychangereliably on iOS when the user swipes away. web-vitals handles this withpagehide, but if you add your own exit logic, hookpagehideandvisibilitychange, neverbeforeunload(which is unreliable on mobile Safari and can be skipped entirely). <KeepAlive>does not change the metric lifecycle. Vue’s<KeepAlive>caches component instances, but the document and its performance timeline are unchanged, so LCP/CLS/INP behave exactly as described. Do not treat an activated<KeepAlive>view as a new page load.- History-mode vs hash-mode routing both count as one document. Whether you use
createWebHistoryorcreateWebHashHistory, every route is the same document to the browser. Neither emits a fresh LCP. The tagging logic is identical; only thefullPathstring differs. - Dev HMR re-runs plugins. Hot-module reload can re-evaluate
main.jsor the Nuxt plugin, re-invokingregisterWebVitals. The module-levelregisteredflag from Step 2 absorbs this in development; production builds register once regardless.
FAQ
Why doesn’t LCP update when I navigate between Vue routes?
Because LCP is a per-document-load metric. A vue-router soft navigation uses history.pushState, which does not create a new document, so the browser never emits a second largest-contentful-paint entry. The LCP you captured on the initial load is the only one for that document; relabel the route for INP and CLS instead of expecting LCP to refresh.
Should the web-vitals plugin go in main.js or a route component?
Always main.js (or the Nuxt plugins/ directory), installed exactly once. Registering inside a route component re-runs on every visit, attaching duplicate observers for INP and CLS while gaining no new per-load data. The plugin install is the correct one-time hook.
How do I attribute a slow INP to the right Vue route?
Tag the active route in router.afterEach via setRoute(to.fullPath) and read that label inside the report callback. INP finalizes at page exit, so without the tag every interaction would appear to belong to the last route viewed; the captured route field fixes the attribution to where it actually happened.
Related
- Framework Performance Instrumentation — the parent guide to wiring Core Web Vitals into JS frameworks.
- Measuring React Hydration’s Impact on LCP — the React-side counterpart, isolating hydration cost on first paint.
- Web Vitals API Implementation — the underlying onLCP/onINP/onCLS API and buffered PerformanceObserver mechanics these plugins build on.