How to Measure LCP for Every Route Change in a Single-Page App

This guide is part of Soft Navigations & SPA Metrics in the Core Web Vitals & Measurement section. It answers a question the standard metric cannot: how long did the new view take to show its main content after the user clicked?

Largest Contentful Paint is defined for a document load. The browser stops emitting largest-contentful-paint entries as soon as the user interacts or scrolls, because after that point "the largest thing painted" stops meaning "the page has loaded". In an SPA, every in-app route change happens after an interaction, so it never gets an LCP. The product detail page your users reach from a category listing — often your most valuable view — has no loading metric at all.

You can close the gap two ways: the experimental Soft Navigations API, which emits contentful-paint entries for soft navigations in Chromium when enabled, and a portable approach using Element Timing and your router's lifecycle. Production setups usually run the portable approach everywhere and use the experimental entries to validate it.

Why the standard metric goes silent Timeline showing LCP entries emitted during the initial load, stopping at the first click, and no entries for the following route change. Why the standard metric goes silent Hard load LCP entries Soft nav new view renders no LCP emitted 0s 1s 2s 3s 4s 5s 6s 7s 8s first click

Rapid Diagnosis

  • Confirm the gap. In DevTools, run new PerformanceObserver(l => console.log(l.getEntries())).observe({type: 'largest-contentful-paint', buffered: true}), click to another route, and note that no new entries arrive.
  • Identify the route's hero element. On the new view, decide which element represents "content has loaded": the product image, the article headline, the first row of results.
  • Check for skeletons. If the route paints a skeleton first, you must decide whether the skeleton or the real content counts. Users perceive the real content; measure that.
  • Note image loading attributes. Route hero images are often loading="lazy" by default in component libraries, which delays them on soft navigations just as on hard loads.

Root Cause Analysis: Why Route LCP Is Hard

1. The browser's heuristic ends at input. LCP stops on interaction by design. Nothing in the standard metric can be "re-armed" for a new view.

2. "Largest" is ambiguous within a persistent shell. The header, sidebar and footer survive the route change. A naive "largest element painted after navigation" can pick a persistent element that re-rendered, not the new content.

3. The navigation start is not the click. The meaningful start time is the interaction that triggered the route, not the moment the router committed. Measuring from commit hides chunk loading and data fetching — the phases most likely to be slow.

4. Images paint after their element is inserted. An <img> inserted at 300ms may not render pixels until 900ms. Measuring DOM insertion instead of paint under-reports the real experience.

Step-by-Step Resolution

1. Anchor the start at the triggering interaction

Record the timestamp of the interaction that caused the navigation, then pass it into the route's measurement.

javascript
let pendingNavStart = null;
addEventListener('click', (e) => {
  const a = e.target.closest('a[href]');
  if (a && a.origin === location.origin) pendingNavStart = e.timeStamp;
}, { capture: true });
router.beforeEach(() => { pendingNavStart ??= performance.now(); });
// trade-off: e.timeStamp is the hardware input time, which includes input delay.
// That is deliberate — users waited through it — but it means a slow click
// handler shows up in both INP and route LCP.

Expected outcome: route timings start where the user's wait starts, typically adding 20–80ms compared with measuring from router commit.

2. Mark each route's hero element with Element Timing

Element Timing (elementtiming attribute) reports the paint time of specific images and text nodes — exactly the primitive needed.

html
<!-- ProductDetail.vue / ProductDetail.tsx -->
<img src="/img/p42-800.avif" width="800" height="800" alt="Trail shoe"
     elementtiming="route-hero" fetchpriority="high">
<h1 elementtiming="route-hero-text">Trail Runner 4</h1>
<!-- trade-off: Element Timing only reports images and text-node containers,
     not arbitrary divs or canvas. A chart-led view needs a manual mark after
     the chart's first draw instead. -->

Expected outcome: a PerformanceElementTiming entry with renderTime for each marked element on each route.

3. Compute route LCP as the latest hero paint

javascript
const heroPaints = [];
new PerformanceObserver((l) => heroPaints.push(...l.getEntries()))
  .observe({ type: 'element', buffered: true });

router.afterEach(async (to) => {
  const start = pendingNavStart; pendingNavStart = null;
  await new Promise((r) => setTimeout(r, 3000));       // allow late images to paint
  const paints = heroPaints.filter((p) => p.renderTime >= start && p.identifier.startsWith('route-hero'));
  const lcp = paints.length ? Math.max(...paints.map((p) => p.renderTime)) - start : null;
  report({ route: to.matched.at(-1)?.path, routeLcp: lcp && Math.round(lcp) });
});
// trade-off: a fixed 3s settle window under-reports routes slower than 3s and
// misses routes the user leaves early. Finalise on the next navigation or on
// visibilitychange instead when slow routes are the ones you care about.

Expected outcome: a routeLcp value per soft navigation. Use segmenting RUM by device and connection dimensions so the p75 is comparable with hard-load LCP.

Route LCP measurement sequence Sequence showing the click timestamp, router navigation, route chunk and data loading, and the hero element paint reported by Element Timing. Route LCP measurement sequence User Router Network Renderer click (t0) chunk + data resolved commit new view hero painted (t1) Route LCP is t1 minus t0 — the hero paint relative to the click, not to the router commit.

4. Cross-check with experimental soft-navigation entries

Where the Soft Navigations API is enabled, Chromium emits a soft-navigation entry and contentful-paint entries for the new view. Log both and compare on a sample.

javascript
if (PerformanceObserver.supportedEntryTypes.includes('soft-navigation')) {
  new PerformanceObserver((l) => {
    for (const nav of l.getEntries()) console.debug('soft-nav', nav.name, Math.round(nav.startTime));
  }).observe({ type: 'soft-navigation', buffered: true });
}
// trade-off: the experimental API's heuristics and entry types have changed
// between origin trials. Use it to validate your own measurement, not as the
// only production source.

Expected outcome: your Element Timing route LCP should agree with the browser's soft-navigation paint within roughly ±100ms on most routes; large disagreements usually mean the wrong hero element is marked.

Turning Route LCP Into Fixes

Once the number exists, its breakdown points at the fix just like hard-load LCP. Subtract the chunk and data timings (from Resource Timing for the route chunk and API calls) to see which phase dominates.

Where route LCP goes on a product detail view Bar chart splitting a 1.7 second route LCP into input, route chunk, data, image load and render phases. Where route LCP goes on a product detail view Input + handler 60ms Route chunk download 310ms Product API (serial x2) 720ms Hero image load 480ms Render + paint 130ms

  • Route chunk dominates → prefetch it on hover or viewport, as described in prefetching route chunks on hover and viewport.
  • Data dominates → start the request in the router's beforeEach in parallel with the chunk, and remove serial dependencies between calls.
  • Image dominates → preload the hero image as soon as its URL is known, and drop loading="lazy" on route hero images.
  • Render dominates → the route renders too much before showing the hero; render above-the-fold content first.

Expected outcome after addressing the dominant phase: route LCP under 1.5s at p75 on mid-tier mobile for routes with cached shells — noticeably faster than any hard load can be, which is the point of an SPA.

Verification

Throttle to "Fast 4G" and 4x CPU, navigate between two routes twenty times, and compare the distribution of routeLcp before and after the fix. In RUM, chart route LCP p75 per route template next to hard-load LCP for the same template: a well-built SPA should show soft-navigation LCP substantially lower. If they are equal, the SPA is paying its JavaScript cost without delivering its speed benefit.

Choosing the Hero Element Per Route Template

The quality of route LCP depends on marking the right element, and the right element is a product decision as much as a technical one. A few rules keep it consistent:

  • One primary marker per template. A product page's hero is the product image; a search results page's is the first result card's image or title; an article's is the headline. Put the elementtiming attribute in the template component so every instance is marked identically.
  • Mark the content, not the container. Element Timing reports images and text; a wrapper div is not reported. Put the attribute on the <img> or on the element directly containing the headline text.
  • Avoid persistent elements. Never mark anything rendered by the app shell — it may repaint on navigation and produce a misleadingly early timestamp.
  • Prefer the larger of image and text. When both exist above the fold, mark both with the same identifier prefix and take the later paint, mirroring how LCP treats the largest candidate as the one that matters.

Review the markers when templates are redesigned. A hero image moved below a new promotional banner is no longer what users wait for, and the metric will quietly start measuring the wrong thing.

FAQ

Can I just call it "LCP" in dashboards?

Label it distinctly — "route LCP" or "soft-nav LCP". It is measured differently from the standard metric and is not what CrUX or Search Console reports. Mixing the two in one series produces numbers nobody can reproduce.

What if the route has no image?

Mark the main heading or first paragraph container with elementtiming. Text-node containers are reported when their text paints, which captures font-loading delays too. For routes whose main content is a canvas or a chart, emit a performance.mark() after the first meaningful draw and use that as the end point.