Measuring Route-Change Latency in React Router
React Router owns the moment a user leaves one screen and arrives at another, and the browser reports nothing about it. No navigation entry, no LCP, no TTFB — the metric that matters most to a single-page application is the one you have to build. This guide sits under Soft Navigations & SPA Metrics and covers where to hook React Router, how to time to the paint rather than to the state update, and how to attribute the result to a route pattern that aggregates.
Prerequisites
- React Router v6 or later, with a data router (
createBrowserRouter) or the classic component API — both are covered below. - A beacon queue that flushes on
visibilitychange, as described in Framework Performance Instrumentation. - An understanding that route changes do not reset LCP, CLS or INP — the model is in Soft Navigations & SPA Metrics.
How to measure it
Step 1 — Start the clock at the intent, not at the render
The most common mistake is measuring from the effect that runs after the route already changed, which excludes the loader and the transition — often most of the wait. Start at the click or the navigation call.
// nav-timing.js — a tiny module the router hooks write into.
let pending = null;
export function markNavStart(to) {
pending = { to, start: performance.now() };
}
export function markNavEnd(routePattern) {
if (!pending) return;
const { start, to } = pending;
pending = null;
// Wait for the paint: two frames after commit is when pixels are on screen.
requestAnimationFrame(() => requestAnimationFrame(() => {
queueBeacon({
metric: 'route_change',
value: Math.round(performance.now() - start),
route: routePattern, // '/products/:id', never '/products/8841'
href: to,
depth: ++window.__routeDepth,
});
}));
}
Step 2 — Hook the data router
With createBrowserRouter, the router exposes its state machine: state.navigation.state moves through idle → loading → idle, and submitting for form actions. Subscribing gives you exact boundaries.
// Subscribe once, outside React, so no re-render is triggered by measurement.
import { markNavStart, markNavEnd } from './nav-timing';
export function instrumentRouter(router) {
let last = router.state.navigation.state;
router.subscribe((state) => {
const now = state.navigation.state;
if (last === 'idle' && now !== 'idle') {
markNavStart(state.navigation.location?.pathname ?? '');
}
if (last !== 'idle' && now === 'idle') {
// The matched route pattern, assembled from the matched route chain.
const pattern = '/' + state.matches
.map((m) => m.route.path)
.filter(Boolean)
.join('/');
markNavEnd(pattern || '/');
}
last = now;
});
}
state.matches is what makes this aggregate: it yields /products/:id rather than the resolved path, which is the low-cardinality key percentiles need.
Step 3 — Hook the component API when you are not on a data router
// useRouteTiming.js — for apps still using <Routes> without a data router.
import { useEffect, useRef } from 'react';
import { useLocation, useMatches } from 'react-router-dom';
import { markNavStart, markNavEnd } from './nav-timing';
export function useRouteTiming() {
const location = useLocation();
const matches = useMatches?.() ?? [];
const first = useRef(true);
const previous = useRef(location.key);
// Start the clock as early as possible on a location change.
if (previous.current !== location.key) {
if (!first.current) markNavStart(location.pathname);
previous.current = location.key;
}
useEffect(() => {
if (first.current) { first.current = false; return; } // skip the initial load
const pattern = matches.map((m) => m.pathname).at(-1) ?? location.pathname;
markNavEnd(pattern);
}, [location.key]);
}
The render-phase start is deliberate: an effect runs after commit, so a markNavStart inside useEffect would miss everything the loader did.
Step 4 — Split the transition from the data
A route change made of a 40 ms transition and a 900 ms loader is a data problem. One made of a 600 ms transition and a 60 ms loader is a rendering problem. Recording both separately is what makes the number actionable.
// Wrap loaders so their duration is reported alongside the route change.
export function timedLoader(name, loader) {
return async (args) => {
const t0 = performance.now();
try {
return await loader(args);
} finally {
queueBeacon({
metric: 'route_loader',
value: Math.round(performance.now() - t0),
route: name,
});
}
};
}
// createBrowserRouter([{ path: '/products/:id',
// loader: timedLoader('/products/:id', productLoader), element: <Product /> }])
Step 5 — Use transitions so the old screen stays interactive
useTransition lets React keep the previous screen responsive while the next one prepares, which changes what the user experiences even when the measured duration is identical. Record whether a navigation was a transition so the two populations do not get mixed.
const [isPending, startTransition] = useTransition();
function goToProduct(id) {
markNavStart(`/products/${id}`);
startTransition(() => navigate(`/products/${id}`));
}
A pending transition that lasts 800 ms with a responsive old screen and a visible indicator is a materially better experience than an 800 ms blank frame, and only the annotation on the beacon lets you tell them apart later.
Step 6 — Preload on intent
Fetching the route chunk and the loader data on hover or touch start removes both from the critical path. React Router’s <Link prefetch> behaviour differs by version and framework wrapper, so the portable version is explicit.
// Prefetch on intent, guarded so it never runs on a constrained connection.
const conn = navigator.connection;
const cheap = !conn || (!conn.saveData && /4g/.test(conn.effectiveType ?? '4g'));
function onIntent(routeId, href) {
if (!cheap) return;
router.routes.find((r) => r.id === routeId)?.lazy?.(); // warm the chunk
void fetch(hrefToApi(href), { priority: 'low' }); // warm the data
}
The connection check matters: speculative fetching on a metered link spends the user’s data on screens they may never open. The signals and their caveats are covered in Segmenting RUM Data by Effective Connection Type.
Verifying it works
- Every navigation produces exactly one
route_changebeacon. Duplicates usually mean both the render-phase start and an effect-based start are firing. - Route values contain
:parameters. Resolved paths mean the matched pattern is not being read. - The initial load is excluded. A
route_changeon the first page view inflates the distribution with something that is not a route change. - Loader durations sum to less than the route change, with the difference being render and paint. If a loader is longer than its route change, the clock started too late.
- Depth is recorded, so you can check whether latency grows through the session — the accumulation problem in Soft Navigations & SPA Metrics.
Turning the measurement into a working target
Once route-change latency is instrumented, the useful next step is a per-destination budget rather than a single application-wide number. Different screens have different justifiable costs, and one target flattens that distinction into an argument.
A workable starting set for a data-driven application:
| Destination type | Target (p75, mid-tier device) | Justification |
|---|---|---|
| Cached, no loader | 250 ms | Nothing to fetch; this is render cost alone |
| Loader hitting a warm API | 500 ms | One round trip plus render |
| Loader with a cold or fan-out API | 800 ms | Multiple upstream calls |
| First visit to a lazy route | 900 ms | Chunk download plus the above |
The value of writing these down is that they make the loader the visible constraint. On most applications the largest single number in the composition chart is an API call nobody had attributed to the frontend before, and the fix — caching it, moving it to the edge, or rendering the shell before it resolves — is a backend conversation that the measurement is what starts.
Track the budget as a share of navigations meeting it rather than as a percentile, for the same reason objectives beat percentiles generally: “89% of route changes to /products/:id completed within 500 ms” is a sentence with an obvious next action. The wider framing is in Performance Budgets & SLOs.
Edge cases and gotchas
- Aborted navigations. A user who clicks twice leaves a dangling start; clear
pendingwhen the router returns to idle with a different location than the one you started timing. - Redirects inside loaders produce two navigations for one user action. Deduplicate on the final matched pattern or report the chain explicitly.
popstateis usually fast and skews the aggregate. Record the navigation type and split it out.- Suspense boundaries hide the wait. A route that commits immediately and then shows a fallback for 900 ms will measure as fast while feeling slow. Time to the content, not to the boundary.
- Strict Mode double-invokes effects in development. The duplicate beacons are a development artefact; check production data before hunting a bug.
- Layout routes contribute empty path segments. Filter falsy segments when assembling the pattern or you get
//products/:id.
FAQ
Should I measure to the commit or to the paint?
To the paint. A commit that has not yet painted is not something the user has seen, and on a slow device the gap between the two is routinely a hundred milliseconds or more.
Why is my route-change number lower than it feels?
Usually because the clock starts after the loader, or because a Suspense fallback commits quickly and the real content arrives much later. Both make the measurement optimistic in exactly the case the user notices.
Does React Router report anything by itself?
No. It exposes navigation state and the matched route chain, which is everything you need to build the measurement — but the timing and the beacon are yours.
What is a reasonable target?
Under 500 ms from intent to painted route on a mid-tier device is a good internal target. Above a second, users start treating the application as a website that is loading pages, which removes most of the reason to be a single-page application.
Related
- Soft Navigations & SPA Metrics — why the browser reports nothing for these navigations.
- Using the Soft Navigations API — the browser-side detection that cross-checks your router hook.
- Measuring React Hydration’s Impact on LCP — the load-phase half of the same application.