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.

One-time registration vs per-navigation behaviour in a Vue SPA The app bootstrap registers the web-vitals plugin a single time. LCP, FCP, and TTFB are captured once per document load and do not reset on router navigations, while INP and CLS keep accumulating across the session. app bootstrap register plugin once onLCP / onINP / onCLS / onFCP / onTTFB callbacks send beacons router navigations (afterEach) per-load metrics LCP / FCP / TTFB do NOT reset on route change session metrics INP / CLS accumulate across routes tag route yourself to.fullPath at nav time attribute the slow view Beacons flush on pagehide / visibilitychange — one finalized value per metric. Good targets: LCP p75 ≤ 2.5 s, INP p75 ≤ 200 ms, CLS p75 ≤ 0.1.
Register once at bootstrap; the router only changes the route label, not the metric lifecycle. See the web-vitals API implementation reference for why per-load metrics stay frozen across SPA navigations.

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

  1. Console sanity check. Temporarily wrap report with console.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.
  2. 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 value your beacon reports for the same id.
  3. RUM endpoint. Watch the collector receive five metric types. After a soft navigation, confirm INP/CLS beacons carry the later route in route (because they finalize at exit) while LCP/FCP/TTFB carry the entry route — exactly the behaviour the table in Prerequisites predicts.
  4. 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.ts instead of web-vitals.client.ts makes Nuxt run it on the server, where navigator and PerformanceObserver are undefined — the render throws. The .client suffix 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 onMounted or 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, not beforeunload. Safari historically does not always fire visibilitychange reliably on iOS when the user swipes away. web-vitals handles this with pagehide, but if you add your own exit logic, hook pagehide and visibilitychange, never beforeunload (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 createWebHistory or createWebHashHistory, every route is the same document to the browser. Neither emits a fresh LCP. The tagging logic is identical; only the fullPath string differs.
  • Dev HMR re-runs plugins. Hot-module reload can re-evaluate main.js or the Nuxt plugin, re-invoking registerWebVitals. The module-level registered flag 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.