How to Measure SPA Route Transitions with performance.mark() and measure()

This guide extends Soft Navigations & SPA Metrics, part of Core Web Vitals & Measurement, with the single most useful custom metric a single-page app can have: route transition time, from the user's click to the new view's content being painted.

It is not a Core Web Vital, and that is its strength. Core Web Vitals are deliberately generic so they can be compared across the web; transition time is specific to your app's architecture, which makes it the number that tells you why navigation feels slow. A product team that watches only INP sees the click handler; one that watches only route LCP sees the end result. Transition time, split into phases with User Timing, shows where the 1.4 seconds between them went.

Five marks that define a route transition The User Timing marks placed at each boundary of a soft navigation, from the click through chunk loading, data loading and render to the painted view. Five marks that define a route transition nav:start click timestamp nav:code route chunk ready nav:data data resolved nav:commit view rendered nav:paint next frame painted

Rapid Diagnosis

  • Time a transition by hand first. Record a Performance trace while clicking between two routes; read the gap between the click event and the first paint that shows the new content. If it exceeds 1s on 4x CPU throttling, users notice.
  • Check the Network track for serial requests. A chunk request followed, after it completes, by an API request is the classic transition waterfall.
  • Check for a loading state. If the route shows a spinner for most of the transition, the data phase dominates.
  • Look for a long task right before paint. A large commit — rendering hundreds of components at once — shows as a single long task between data arriving and the paint.

Root Cause Analysis

1. Lazy route chunks fetched on click. Code-split routes are good for initial load, but if the chunk is first requested when the user clicks, every transition pays a network round trip plus parse and evaluate.

2. Data fetched after mount. Components that fetch in useEffect or onMounted cannot start loading until the chunk is evaluated and the component has rendered once — a guaranteed serial waterfall.

3. Over-rendering on commit. The new route renders its entire tree, including below-the-fold content and heavy widgets, before the browser can paint anything.

4. Blocking work in the old route's teardown. Unmount logic — saving state, tearing down charts, cancelling subscriptions — runs synchronously before the new view renders.

Step-by-Step Resolution

1. Place marks at each phase boundary

Use the router's lifecycle hooks and a tiny helper so every route is instrumented identically.

javascript
// nav-timing.js
let navId = 0;
export function navMark(phase, detail = {}) {
  performance.mark(`nav:${phase}`, { detail: { navId, ...detail } });
}
export function beginNav(route, startTime) {
  navId += 1;
  performance.mark('nav:start', { startTime, detail: { navId, route } });
}
export function endNav(route) {
  requestAnimationFrame(() => setTimeout(() => {      // after the next paint
    navMark('paint');
    const m = (from, to) => performance.measure(`nav:${from}->${to}`, `nav:${from}`, `nav:${to}`).duration;
    report({ route, total: m('start', 'paint'), code: m('start', 'code'),
             data: m('code', 'data'), render: m('data', 'paint') });
  }, 0));
}
// trade-off: performance.measure() between named marks uses the MOST RECENT
// mark of each name. Overlapping navigations corrupt the result; check navId
// in mark details or clear marks at each beginNav() in apps with rapid clicking.

Expected outcome: every soft navigation yields a total and three phase durations.

2. Wire the marks into the router

javascript
// Vue Router example — React Router and SvelteKit have equivalent hooks.
router.beforeEach((to, from) => beginNav(to.path, lastClickTime ?? performance.now()));
router.beforeResolve(() => navMark('code'));              // async components resolved
router.afterEach((to) => {
  dataReady(to).then(() => { navMark('data'); endNav(to.matched.at(-1)?.path); });
});
// trade-off: beforeResolve fires after async route components resolve, which is
// the right "code" boundary only if data loading is NOT also triggered there.
// If your data loaders run in beforeResolve, put the code mark in the chunk import.

Expected outcome: marks visible in the Performance panel's Timings track, aligned with network requests — the fastest way to see a waterfall.

A slow transition before and after parallel loading Two timelines comparing a serial chunk-then-data transition taking 1.3 seconds with a parallel version finishing in 0.7 seconds. A slow transition before and after parallel loading Serial chunk data after mount render Parallel chunk data render 0ms 200ms 400ms 600ms 800ms 1000ms 1200ms 1400ms 1s budget In the parallel version the data request starts with the chunk request, so only its last 120ms shows after the chunk arrives.

3. Start data loading at navigation start, not on mount

Move the data request out of the component and into a route-level loader that fires in parallel with the chunk.

javascript
const loaders = { '/product/:id': (p) => fetch(`/api/product/${p.id}`).then((r) => r.json()) };
const pending = new Map();
router.beforeEach((to) => {
  const loader = loaders[to.matched.at(-1)?.path];
  if (loader) pending.set(to.fullPath, loader(to.params));   // starts NOW
});
export const useRouteData = (route) => pending.get(route.fullPath);
// trade-off: starting data on beforeEach means navigations the user abandons
// still hit the API. Pass an AbortController and abort on the next navigation.

Expected outcome: the data phase shrinks by roughly the chunk download time — often 200–400ms on mobile.

4. Render the first screen first

Defer below-the-fold sections of the route with content-visibility: auto, lazy components, or a deliberate second render pass after paint, so the commit before the first paint stays under 50ms of main-thread work.

Expected outcome: the render phase falls below 200ms even on mid-tier devices, and INP for the navigation click improves as a side effect.

Budgeting Transitions in CI and RUM

Lab assertions catch architectural regressions (a chunk that stopped being prefetched); RUM budgets catch real-world drift.

javascript
// playwright test — assert route transition stays under budget in the lab.
test('product route transition', async ({ page }) => {
  await page.goto('/shoes');
  await page.click('a[href="/shoes/42"]');
  await page.waitForFunction(() => performance.getEntriesByName('nav:paint').length > 0);
  const total = await page.evaluate(() =>
    performance.getEntriesByName('nav:start->paint').at(-1)?.duration);
  expect(total).toBeLessThan(600);
});
// trade-off: CI runs on fast machines with warm caches, so a 600ms lab budget
// corresponds to a much slower field p75. Treat lab budgets as regression
// tripwires and set user-facing targets from RUM.

Route transition time targets (field p75) A threshold scale for route transition time showing instant, acceptable and slow ranges with a current p75 marker. Route transition time targets (field p75) Instant 300ms Acceptable 1000ms Feels like a page load current p75: 1340ms

Verification

After each change, compare phase distributions rather than only the total. A successful prefetch shows up as a code phase near zero; a successful parallel loader shows the data phase shrinking by the chunk time; render work moved after paint shrinks render. If the total improves but one phase grows, a fix has moved cost rather than removed it — common when prefetching makes the chunk arrive earlier but its evaluation then competes with the data parse.

Handling the Awkward Navigations

A transition metric is only trustworthy if it treats edge cases consistently. Decide these rules once and encode them in beginNav/endNav.

Abandoned navigations. The user clicks route A, then route B before A paints. Record A with an aborted flag and its partial phases, and start B fresh. Excluding aborted navigations entirely hides your slowest experiences — users abandon precisely the slow ones.

Cached routes. Back/forward navigations, and routes whose data is already in a client cache, often transition in under 100ms. They are real and good, but mixing them with cold transitions makes the p75 look better than the experience of a first visit to a route. Tag cache: 'warm' | 'cold' and report both.

Background tabs. If the tab is hidden during a transition, requestAnimationFrame stops firing and your paint mark lands when the user returns — possibly minutes later. Check document.visibilityState at endNav and discard transitions that spanned a hidden period.

Redirects inside the router. A guard that redirects /account to /login produces two navigations in quick succession. Attribute the total to the user's intent (the first route) and record the redirect as a phase; otherwise the redirect target looks fast and the original route looks like it never completed.

Transitions that open modals. Some apps change the URL when a modal opens. Count these as transitions only if your product considers them views; otherwise filter them by route pattern so they do not dilute the p75 of real navigations.

FAQ

Should transition time be tracked per route template or per URL?

Per template. /product/:id has one code path and one data shape regardless of which product, so its transition time is one distribution. Per-URL tracking adds cardinality without adding diagnostic value, except for a handful of outlier pages (a product with 400 variants) that are better found by sampling.

How does this relate to route LCP?

They measure overlapping spans with different end points. Transition time ends at the first paint after your app considers the route ready; route LCP ends when the hero element paints, which may be later if the hero is an image still loading. Track both — transition time is what your code controls directly, route LCP is closer to what the user sees.