Instrumenting Core Web Vitals in SvelteKit

SvelteKit renders on the server, hydrates on the client, and then handles every subsequent navigation itself. Each of those three phases affects a different metric, and a reporter mounted in the wrong place will miss one of them entirely. This guide sits under Framework Performance Instrumentation and covers where to mount the reporter, how to attach the matched route id rather than the URL, and what SvelteKit’s navigation model does to metrics that are scoped to a document.

Prerequisites

  • A SvelteKit application with client-side routing enabled (the default).
  • The web-vitals package installed, with the attribution build available for debugging.
  • A collector endpoint that accepts a JSON beacon, as described in Self-Hosted Beacon Collection.
  • An understanding that client-side navigations do not reset metrics — the model is set out in Soft Navigations & SPA Metrics.

How to instrument it

Step 1 — Mount the reporter once, in the root layout

The root layout persists across client-side navigations, which is exactly what you need: observers registered once keep running, and nothing is torn down when the user moves between routes.

// src/routes/+layout.svelte
<script>
  import { onMount } from 'svelte';
  import { browser } from '$app/environment';
  import { page } from '$app/stores';
  import { startReporting, setRoute } from '$lib/vitals';

  onMount(() => {
    if (!browser) return;
    startReporting();                    // registers observers exactly once
  });

  // Keep the current route id up to date for every beacon that follows.
  $: if (browser && $page.route?.id) setRoute($page.route.id);
</script>

<slot />

Mounting in a +page.svelte instead is the mistake to avoid: the component unmounts on navigation, taking its observers with it, and every metric after the first route change disappears.

Step 2 — Report the route id, never the pathname

SvelteKit gives you $page.route.id — the file-system route such as /products/[slug] — which is exactly the low-cardinality key an aggregate needs.

// src/lib/vitals.js
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';

let route = '/';
let started = false;
const queue = [];

export function setRoute(id) { route = id; }

function push(metric) {
  queue.push({
    metric: metric.name,
    value: Math.round(metric.value * 1000) / 1000,
    rating: metric.rating,
    id: metric.id,
    route,                                   // '/products/[slug]', not '/products/8841'
    navType: metric.navigationType,          // navigate | reload | back-forward-cache
    ts: Date.now(),
  });
}

function flush() {
  if (!queue.length) return;
  const body = JSON.stringify({ beacons: queue.splice(0, queue.length) });
  navigator.sendBeacon('/rum', body);
}

export function startReporting() {
  if (started) return;
  started = true;
  onLCP(push);
  onCLS(push);
  onINP(push);
  onFCP(push);
  onTTFB(push);
  addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') flush();
  });
}

Step 3 — Measure the navigations SvelteKit handles itself

beforeNavigate and afterNavigate bracket a client-side navigation precisely, which makes route-change latency a two-line measurement.

// src/routes/+layout.svelte — inside the same <script> block.
import { beforeNavigate, afterNavigate } from '$app/navigation';

let navStart = 0;

beforeNavigate(() => { navStart = performance.now(); });

afterNavigate((nav) => {
  if (!navStart || nav.type === 'enter') return;      // 'enter' is the initial load
  const duration = performance.now() - navStart;
  // Wait for paint so the number reflects what the user saw.
  requestAnimationFrame(() => requestAnimationFrame(() => {
    reportCustom('route_change', duration, {
      from: nav.from?.route?.id ?? null,
      to: nav.to?.route?.id ?? null,
      type: nav.type,                                  // link | popstate | goto | form
    });
  }));
  navStart = 0;
});

The type field is worth keeping. A popstate navigation is usually near-instant because the data is cached, while a link navigation may involve a load function running on the server — averaging them hides both.

p75 route-change duration by navigation typeServer load functions dominate the slower navigations. Preloading on hover moves most link navigations into the cached band.p75 route-change duration by navigation typeMeasured between beforeNavigate and the paint after afterNavigatepopstate (back)180 mslink, data cached320 mslink, server load740 msform submission1,120 ms0 ms500 ms1,000 ms1,500 ms2,000 ms
Server load functions dominate the slower navigations. Preloading on hover moves most link navigations into the cached band.

Step 4 — Use SvelteKit preloading deliberately

data-sveltekit-preload-data="hover" starts the load function when the user hovers, which typically removes the entire server round trip from the perceived navigation. It is set per link or per container, and the trade-off is speculative work.

<!-- In app.html or a layout: preload data on hover, code on tap. -->
<body data-sveltekit-preload-data="hover" data-sveltekit-preload-code="tap">

On metered or slow connections, prefer tap for data too — the guidance for detecting those conditions is in Segmenting RUM Data by Effective Connection Type.

Step 5 — Keep server-side rendering out of the reporter

Any module-level code touching window, document or performance runs during SSR and throws. Guard with the browser flag from $app/environment rather than typeof window, so the check is statically removed from the server bundle.

import { browser } from '$app/environment';

export function reportCustom(name, value, extra = {}) {
  if (!browser) return;                    // tree-shaken out of the server build
  queue.push({ metric: name, value: Math.round(value), route, ...extra });
}

Step 6 — Split prerendered routes from dynamic ones

SvelteKit can prerender a route at build time, serve it as a static file, and skip the server load entirely. That is a large TTFB difference, and mixing the two in one aggregate produces a number that describes neither. Stamp the rendering mode on the beacon so the split is available later.

// In the root layout, once: read what the server told the client.
import { browser } from '$app/environment';

const renderMode = browser
  ? (document.documentElement.dataset.render || 'dynamic')
  : 'ssr';

// app.html: <html data-render="%sveltekit.render_mode%"> filled by a hook,
// or simply hard-code it per route group if the split is static.

The resulting cut is usually stark — prerendered routes land in the low hundreds of milliseconds for TTFB while dynamic ones carry the load function and any upstream API call. Ranking dynamic routes by their TTFB contribution is the fastest way to find the load function that deserves caching.

Step 7 — Watch what hydration costs on the routes that matter

SvelteKit ships less JavaScript than most frameworks, but hydration is still main-thread work that lands squarely in the window where the largest element paints. Two marks bracket it well enough to trend:

// In the root layout's onMount, which runs after hydration completes.
performance.mark('hydrate:end');
const nav = performance.getEntriesByType('navigation')[0];
reportCustom('hydration', performance.now() - nav.responseEnd);

Comparing that number against the p75 LCP for the same route tells you whether hydration is on the critical path. Where it is, the fix is structural — keep the largest element out of the hydrated tree — and the reasoning transfers directly from the React case.

p75 TTFB by rendering modeMixing these four into one origin TTFB produces a number that describes none of them. The rendering mode belongs on the beacon.p75 TTFB by rendering modeSame application, prerendered against server-rendered routesPrerendered, edge cache hit96 msPrerendered, cache miss210 msServer load, cached data480 msServer load, upstream API1,140 ms0 ms500 ms1,000 ms1,500 ms2,000 msGood 800 ms
Mixing these four into one origin TTFB produces a number that describes none of them. The rendering mode belongs on the beacon.

Verifying it works

  1. Beacon coverage above about 95% of sessions. Below that, the reporter is being torn down or the flush is not firing — the comparison table in Vue & Nuxt PerformanceObserver Setup shows what each mounting mistake costs.
  2. Every beacon carries a route id with brackets in it for dynamic routes. Raw pathnames mean setRoute is reading the wrong value.
  3. LCP appears only on navigate and back-forward-cache types. An LCP attributed to a client-side navigation means something is reporting a stale value.
  4. Route-change durations exist for every navigation type, including popstate.
  5. No SSR errors in the server log referencing the vitals module.

What the field data usually shows first

Three findings recur on almost every SvelteKit application once the beacons start arriving, and knowing them in advance saves a week of investigation.

Prerendered routes are dramatically faster, and they flatter the aggregate. A marketing site where half the routes are prerendered will report an origin TTFB that no dynamic route ever achieves. Split the two before anyone sets a target.

The first client-side navigation is slower than the ones after it. The route bundle for the destination has to be fetched on the first visit and is cached afterwards, so the distribution is bimodal. Preloading on hover collapses it, and the effect is visible within a day.

INP degrades with session depth even though the framework is light. SvelteKit ships less JavaScript than most, but the document still lives for the whole session, so listeners, stores and subscriptions accumulate exactly as they do anywhere else. The teardown discipline matters more than the bundle size.

The corollary for reporting: an aggregate that mixes prerendered and dynamic routes, first and subsequent navigations, and shallow and deep sessions is averaging six different populations. Every one of those splits is a single extra field on the beacon, and each of them is worth more than another decimal place on the headline number.

Where the reporter sits in a SvelteKit sessionThe root layout is the only component that survives every client-side navigation, which is why the registration belongs there and nowhere else.Where the reporter sits in a SvelteKit sessionOne registration, many navigationsRoot layoutonMount, browser onlyObserversregistered onceRoute storestamps every beaconFlush on hidesendBeacon
The root layout is the only component that survives every client-side navigation, which is why the registration belongs there and nowhere else.

Edge cases and gotchas

  • $page.route.id is null during the very first render in some versions. Default to the pathname once, then let the reactive statement correct it.
  • Reporting inside load functions. Load functions run on both server and client; measurement code there runs twice and once without a DOM.
  • afterNavigate fires on the initial load with type: 'enter'. Filter it, or every session records a phantom route change.
  • Shallow routing changes the URL without a navigation. pushState from $app/navigation does not trigger afterNavigate in the same way; stamp the route explicitly if you use it.
  • Hydration cost lands on LCP when the largest element is inside a hydrated island — the measurement approach transfers directly from Measuring React Hydration’s Impact on LCP.
  • Prerendered pages have no server load time, so their TTFB is a CDN number. Segment prerendered from dynamic routes before comparing.

FAQ

Where exactly should the reporter live in a SvelteKit app?

In the root +layout.svelte, inside onMount, guarded by the browser flag. That component persists across client-side navigations, so the observers registered there survive the whole session.

Why is INP so much worse on later screens?

Because INP is scoped to the document and a SvelteKit session keeps one document alive across every navigation. The accumulation problem and its fixes are covered in Soft Navigations & SPA Metrics.

Should I use $page.url.pathname if route.id is unavailable?

Only as a fallback, and normalise it — replace numeric and UUID segments with placeholders. Raw pathnames create unbounded cardinality that makes percentiles impossible to compute per route.

Does data-sveltekit-preload-data hurt anything?

It performs speculative work on hover, which costs bandwidth and server load for navigations that never happen. On fast connections it is nearly always worth it; on constrained ones, prefer preloading on tap.