Instrumenting User Timing Marks in an SPA

In a single-page application the browser fires its navigation timeline exactly once, at the initial document load. Every route change after that is a JavaScript-driven view swap the platform never sees, so the metrics that matter most to users — how long a “soft” navigation takes from click to painted view — are invisible unless you instrument them yourself. This page is part of the User Timing API: Marks & Measures cluster and shows the exact, runnable pattern for marking route-change intent, marking the moment the new view actually paints, measuring the span between them, and shipping that span to your RUM backend without leaking entries into the performance buffer.

The technique is deliberately framework-agnostic: performance.mark() and performance.measure() are platform APIs, and the only framework-specific glue is a single router hook. We capture the result through a PerformanceObserver subscribed to the standard performance entry buffer, the same mechanism the web-vitals library uses, then forward it through your existing beacon path.

SPA route-transition User Timing sequence A click triggers route-change-start, the router renders the new view, a double requestAnimationFrame marks route-rendered, performance.measure spans the two marks, and a PerformanceObserver beacons the duration to RUM. User / Router User Timing RUM backend click / navigate() mark: route-change-start render + double rAF mark: route-rendered measure(start, end) observer beacons duration p75 route latency
Two marks bracket the transition; the measure between them is observed and beaconed, then aggregated at p75. See the parent User Timing cluster.

Prerequisites

Before wiring this up, confirm the following are in place:

  • A client-side router that exposes a navigation hook — a beforeEach/afterEach guard, a history listener, or an effect keyed on the active route. Any router that can run a callback at navigation intent and again after the destination component commits will work.
  • An existing beacon transport. This page reuses your self-hosted beacon collection endpoint and assumes navigator.sendBeacon is available, with a fetch fallback.
  • A feature-detection guard. performance.mark, performance.measure, and PerformanceObserver with type: 'measure' are widely supported, but you must no-op gracefully where they are absent rather than throw inside a router guard.
  • A stable route identifier (the matched route pattern, e.g. /product/:id, not the concrete URL) so cardinality stays bounded when you aggregate.

How to instrument route-transition marks

1. Detect support and define stable mark names

Centralise feature detection and naming so every call site is consistent. Using a fixed route-change-start mark name plus a per-transition route-rendered:<id> measure name keeps the buffer query simple while still tagging the destination.

const utSupported =
  typeof performance !== 'undefined' &&
  typeof performance.mark === 'function' &&
  typeof performance.measure === 'function' &&
  typeof PerformanceObserver === 'function';

const START_MARK = 'route-change-start';

function renderedMark(routeId) {
  return `route-rendered:${routeId}`;
}
function measureName(routeId) {
  return `route-transition:${routeId}`;
}

Why: a hard support gate means the rest of the code can assume the APIs exist, and deriving names from a single helper prevents typos that would silently break the measure (an unmatched mark name throws a SyntaxError in performance.measure).

2. Mark navigation intent the instant the route starts changing

Call this synchronously inside the router’s “before navigation” hook — at the click or programmatic navigate(), not after data loads. That start boundary is what makes the number reflect perceived latency.

function markRouteStart() {
  if (!utSupported) return;
  // Clear any orphaned start mark from an aborted transition.
  performance.clearMarks(START_MARK);
  performance.mark(START_MARK, {
    detail: { navStart: performance.now() },
  });
}

Why: clearing first guarantees that a cancelled navigation (user clicks B while A is still resolving) does not leave a stale route-change-start that would inflate the next measure. The detail payload rides along with the entry and is readable later from the observer.

3. Mark the rendered boundary after the new view actually paints

The hard part of SPA timing is that a component “mounting” is not the same as the user seeing it. Schedule the end mark inside a double requestAnimationFrame: the first rAF fires before the paint that flushes your DOM mutation, the second fires after that paint has been committed.

function markRouteRendered(routeId) {
  if (!utSupported) return;
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      const endName = renderedMark(routeId);
      performance.mark(endName);
      try {
        performance.measure(measureName(routeId), START_MARK, endName);
      } catch {
        // Start mark missing (e.g. hard reload mid-transition) — skip.
      } finally {
        // Clean up immediately so marks never accumulate.
        performance.clearMarks(START_MARK);
        performance.clearMarks(endName);
        performance.clearMeasures(measureName(routeId));
      }
    });
  });
}

Why: a single rAF callback runs before the next paint, so the new view would not yet be on screen. The second nested rAF defers your mark until after the browser has painted the committed frame, which is the closest cross-browser proxy for “the user can see the new route”. Clearing the marks and the measure in the finally block is essential — without it, every transition leaves three entries in the performance buffer, and a long session can hit the buffer cap and start dropping entries you care about elsewhere.

4. Observe the measure and forward it to RUM

Read the duration through a PerformanceObserver subscribed to measure entries rather than reading the return value of performance.measure() directly. A single long-lived observer decouples capture from the router and gives you one place to enrich and beacon. Note that we clear the measure in step 3 after the observer’s microtask-batched callback has already received a copy of the entry.

function startRouteObserver(report) {
  if (!utSupported) return () => {};
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (!entry.name.startsWith('route-transition:')) continue;
      report({
        metric: 'spa_route_transition',
        route: entry.name.slice('route-transition:'.length),
        duration: Math.round(entry.duration),
        startTime: Math.round(entry.startTime),
      });
    }
  });
  observer.observe({ type: 'measure', buffered: true });
  return () => observer.disconnect();
}

Why: filtering by the route-transition: prefix keeps the observer from beaconing unrelated measure entries created by other libraries. buffered: true replays any measures created before the observer attached, so an early transition during bootstrap is not lost. Rounding to integer milliseconds shrinks the beacon payload and avoids leaking high-resolution timers.

5. Beacon the result on the existing RUM transport

Buffer entries and flush them with navigator.sendBeacon so the request survives the page being hidden or torn down. Slow route transitions are themselves a signal of poor interactivity that also shows up in INP, so it is worth sending both metrics through the same pipe.

const queue = [];
function reportRouteTiming(sample) {
  queue.push({ ...sample, ts: Date.now() });
}

function flush() {
  if (queue.length === 0) return;
  const body = JSON.stringify({ events: queue.splice(0, queue.length) });
  const url = '/rum/collect';
  if (navigator.sendBeacon) {
    navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
  } else {
    fetch(url, { method: 'POST', body, keepalive: true });
  }
}

addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});
addEventListener('pagehide', flush);

const stopObserver = startRouteObserver(reportRouteTiming);

Why: flushing on visibilitychange → hidden and pagehide is the only reliable lifecycle moment to send data on mobile, where tabs are frozen or killed without an unload. Batching into one beacon per flush keeps request volume low for users who navigate many routes per session.

6. Wire the two marks into a router hook

The router-specific glue is small. Below is a framework-agnostic History API version and a Vue Router equivalent; the React Router pattern is identical in shape (mark in an event handler / navigation callback, mark rendered in a useEffect keyed on the location).

// Framework-agnostic: intercept history pushState + popstate.
const _push = history.pushState;
history.pushState = function (...args) {
  markRouteStart();
  const result = _push.apply(this, args);
  const routeId = routePatternFor(location.pathname);
  markRouteRendered(routeId);
  return result;
};
addEventListener('popstate', () => {
  markRouteStart();
  markRouteRendered(routePatternFor(location.pathname));
});

// routePatternFor maps a concrete path to a low-cardinality pattern.
function routePatternFor(pathname) {
  return pathname.replace(/\/\d+(?=\/|$)/g, '/:id');
}
// Vue Router: start on intent, rendered after the destination commits.
router.beforeEach((to, _from, next) => {
  markRouteStart();
  next();
});
router.afterEach((to) => {
  markRouteRendered(to.matched[0]?.path ?? to.path);
});

Why: beforeEach/pushState runs at intent, before any lazy-loaded chunk or data fetch resolves, so the measured span includes that work — which is exactly what the user waits through. afterEach (or the post-pushState call) runs once the destination route is committed, at which point the double-rAF in step 3 waits for the paint. Mapping to a route pattern keeps aggregation cardinality bounded.

Verifying it works

Confirm the instrumentation end to end before trusting the numbers:

  • DevTools Performance panel. Record a route transition. Your route-transition:<id> measure appears in the Timings track as a labelled span; its width should visually match the gap between the click and the new view rendering.
  • Console probe. Temporarily log inside the observer: console.table(performance.getEntriesByType('measure')) immediately after a transition should return an empty array, proving step 3’s cleanup ran. If entries linger, your clearMarks/clearMeasures calls are not firing.
  • Network tab. Filter to /rum/collect. Hide the tab (switch away) and confirm a beacon fires with the queued spa_route_transition events in the request payload.
  • RUM dashboard. Query the new spa_route_transition metric grouped by route and chart the p75. A healthy soft navigation should land well under your LCP budget; transitions trending toward and past 2.5 s are the ones to investigate.

Edge cases & gotchas

Scenario Symptom Mitigation
Aborted navigation (user reroutes mid-transition) Inflated or negative-looking measures clearMarks(START_MARK) at the top of markRouteStart (step 2) discards the stale start
Background tab during transition rAF is throttled/paused, end mark delayed Treat outliers as suspect; segment out hidden-tab samples using document.visibilityState captured in detail
Safari PerformanceObserver quirks Some measure entries missing without buffered Always pass buffered: true so early measures replay on attach
Lazy-loaded route chunk Long, legitimate transition time This is real latency the user feels — keep it in the measure, but tag the route so you can isolate code-split routes
High-cardinality URLs Buffer and dashboard cardinality explosion Always reduce concrete paths to patterns via routePatternFor before naming the measure
Buffer overflow in long sessions Other entries silently dropped The step-3 finally cleanup is non-optional; never leave marks/measures behind

FAQ

Why use a double requestAnimationFrame instead of a single one?

A single requestAnimationFrame callback runs before the upcoming paint, so the new view is not yet visible when it fires. Nesting a second rAF inside the first defers your end mark until after the browser has painted the frame containing your DOM mutation, which is the closest portable approximation of when the user actually sees the new route.

Should I read performance.measure()'s return value or use an observer?

Use a PerformanceObserver with type: 'measure'. It decouples capture from the router, gives you one enrichment-and-beacon site, and with buffered: true it replays measures created before the observer attached. Reading the return value works but scatters reporting logic across every call site.

Do I have to clear the marks and measures?

Yes. Every transition creates a start mark, an end mark, and a measure. Left in the buffer, they accumulate across a long session and can hit the entry-buffer cap, causing the browser to drop later entries — including ones other tools rely on. Clear them in the finally block right after the measure is taken.