Measuring Client-Side Transition LCP

In a single-page app, most of a user's page views never touch the network document. They tap a link, a router swaps the view, and the largest hero renders — but the browser's Largest Contentful Paint metric already finished measuring on the first hard navigation and never fires again. This how-to is part of the Single-Page App Performance Budgets reference, and it shows how to reconstruct an LCP-equivalent number for every client-side route change so those transitions get a real budget instead of a blind spot.

The problem is not academic. On a content-heavy SPA, the first load might be a well-optimized 2200 ms LCP at P75 on mid-range mobile over Fast 3G, while an in-app route to a product detail view paints its hero 1800 ms after the tap — invisible to Core Web Vitals and to your dashboards. If you already budget soft-navigation INP in SPAs, transition LCP is the render-side companion metric: INP tells you how fast the app responded to the tap, transition LCP tells you how fast the destination view actually painted.

Why Native LCP Stops After the First Navigation

The LCP algorithm is bound to a single document lifecycle. The browser watches contentful paints, keeps promoting the largest one, and then freezes the value the moment the user first interacts or the page is hidden. A client-side route change is not a new document — the URL updates through the History API, the DOM mutates, but PerformanceObserver never emits a fresh largest-contentful-paint entry. Every subsequent view inherits a metric that describes a screen the user left long ago.

Two APIs let us rebuild the signal. Element Timing (elementtiming attributes plus the element entry type) reports the render time of specifically annotated elements at any point in the page's life, including after route changes. The Soft Navigations API exposes a soft-navigation performance entry that marks when the browser heuristically detects an in-app navigation, giving us the t=0 reference the tap started from. Combining them — measure the largest element that paints, subtract the soft-navigation start — yields a defensible transition-LCP approximation.

Native LCP coverage across an SPA session A session timeline where native LCP captures only the initial load and three subsequent route transitions have no metric. Hard load Native LCP fires Route A Route B Route C no metric no metric no metric Where the browser measures LCP Green = measured by the platform, crimson = your blind spot
Native LCP covers the first paint only; every in-app transition after it is unmeasured unless you instrument it.

Approximating Transition LCP With Element Timing and Soft-Navigation Entries

The recipe has three moving parts. First, annotate the elements that are plausibly the largest contentful paint of each destination view — hero images, banner headings, first paragraph blocks — with the elementtiming attribute so they become observable. Second, subscribe to soft-navigation entries to learn the timestamp the transition began. Third, for the window between one soft navigation and the next, take the maximum renderTime (or loadTime for images) among the element entries and subtract the soft-navigation startTime. That difference is your transition LCP.

Element Timing reports both a loadTime (when an image resource finished loading) and a renderTime (when it was painted); for cross-origin images without Timing-Allow-Origin, renderTime is zero, so fall back to loadTime. Text elements only report renderTime. Taking the larger element by rendered area, not the latest-arriving one, mirrors how native LCP picks its candidate and keeps the two metrics comparable. Because the Soft Navigations API is still emerging, always guard the observer in a feature check and degrade gracefully to a History-patched fallback when the entry type is unavailable.

Transition LCP computation pipeline A flow from user tap to soft-navigation start, element-timing paints, largest-element selection, and a subtraction that yields transition LCP. User tap link / router soft-navigation startTime = t0 element entries renderTime each largest element max render area Transition LCP render minus t0
The soft-navigation entry supplies t0; the largest element paint supplies the end; their difference is transition LCP.

Building a Custom Transition-LCP Beacon

The following observer wires the pieces together. It tracks the current soft-navigation start, collects candidate elements per transition, and emits a transition-LCP value shortly after the view settles. It leans on the same PerformanceObserver primitives covered in injecting custom metrics via PerformanceObserver, so it slots into an existing RUM pipeline without new dependencies.

// transition-lcp.js — approximate LCP for SPA client-side route changes.
function initTransitionLcp(report) {
  let navStart = performance.now();
  let route = location.pathname;
  let largest = 0;
  let settleTimer = null;

  function area(entry) {
    const rect = entry.intersectionRect || {};
    return (rect.width || 0) * (rect.height || 0);
  }

  function flush() {
    if (largest > 0) {
      report({
        metric: "transition_lcp",
        route,
        value: Math.round(largest - navStart),
        ts: Date.now()
      });
    }
  }

  function startTransition(pathname, startTime) {
    flush();
    navStart = startTime;
    route = pathname;
    largest = 0;
  }

  // Element Timing: candidate paints for the current view.
  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      const end = entry.renderTime || entry.loadTime || 0;
      if (end > navStart && area(entry) > 0) {
        largest = Math.max(largest, end);
      }
    }
    clearTimeout(settleTimer);
    settleTimer = setTimeout(flush, 300);
  }).observe({ type: "element", buffered: true });

  // Soft Navigations API when available.
  if (PerformanceObserver.supportedEntryTypes.includes("soft-navigation")) {
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        startTransition(new URL(entry.name).pathname, entry.startTime);
      }
    }).observe({ type: "soft-navigation", buffered: true });
  } else {
    // Fallback: patch History so a router push resets the window.
    const push = history.pushState;
    history.pushState = function (...args) {
      startTransition(String(args[2] || location.pathname), performance.now());
      return push.apply(this, args);
    };
    addEventListener("popstate", () =>
      startTransition(location.pathname, performance.now())
    );
  }

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

Mark the paint candidates in your components so the observer has something to see. Any element likely to be the destination hero should carry the attribute:

export function ProductHero({ image, title }) {
  return (
    <header className="hero">
      <img src={image} elementtiming="route-hero" alt="" />
      <h1 elementtiming="route-heading">{title}</h1>
    </header>
  );
}

Send the value with navigator.sendBeacon so it survives the next transition or a tab close; the payload shape and flush cadence follow the pattern in batching beacons with sendBeacon.

Setting Per-Route Transition-LCP Targets

A single global transition-LCP budget is nearly useless because routes differ wildly: a text-only settings pane paints in a few hundred milliseconds, while an image-heavy product view has to decode a hero. Budget per route, and always at a stated percentile and device class. Because these are field-derived numbers, express every target as a P75 (or P90 for premium flows) on a named device and connection, the same discipline described in percentile-based threshold tuning.

The table below shows a worked target set. Transition LCP is generally faster than a hard-load LCP because the shell, framework, and often the data cache are already warm — so budgets tighter than your first-load numbers are realistic. Anchoring them to the same device profile you use for realistic LCP budgets keeps the two metrics on one scale.

Route Device + connection Percentile Transition LCP target (ms)
Home to product list Mid-range mobile, Fast 3G P75 1600
Product list to detail Mid-range mobile, Fast 3G P75 1800
Detail to checkout Mid-range mobile, Fast 3G P90 2000
Any route (settings, text) Mid-range mobile, Fast 3G P75 900
Home to product list Desktop, cable P75 700
Product list to detail Desktop, cable P75 850
Measured transition LCP versus per-route budget Four routes with measured P75 transition LCP bars compared against dashed budget lines, one route exceeding its budget. 2000 1400 800 List 1550 Detail 1720 Checkout 2300 Settings 820 P75 transition LCP, mid-range mobile / Fast 3G -- budget
Bars are measured P75 values; dashed lines are the per-route budgets. Checkout breaches its P90 ceiling and should fail the gate.

Gating in CI With a User Flow

Field percentiles catch regressions after they ship; a CI gate catches them before merge. Because transition LCP only exists once a route change happens, a single-page Lighthouse run cannot see it — you need a user flow that navigates in-app and snapshots the metric at each step. A Playwright script that drives the real router and reads back the beaced values is the most portable option, and it pairs naturally with the synthetic-versus-field reasoning in WebPageTest vs RUM for field data.

// transition-lcp.spec.js — fail CI if a route transition breaches its budget.
import { test, expect } from "@playwright/test";

const BUDGETS = {
  "/products": 1600,
  "/products/42": 1800,
  "/checkout": 2000
};

test("client-side transitions stay within budget", async ({ page }) => {
  const samples = [];
  await page.exposeFunction("__reportTLcp", (m) => samples.push(m));
  await page.addInitScript(() => {
    window.__hook = (m) => window.__reportTLcp(m);
  });

  await page.goto("/", { waitUntil: "networkidle" });
  await page.getByRole("link", { name: "Shop" }).click();
  await page.waitForURL("**/products");
  await page.getByRole("link", { name: "Product 42" }).click();
  await page.waitForURL("**/products/42");
  await page.getByRole("button", { name: "Checkout" }).click();
  await page.waitForURL("**/checkout");
  await page.waitForTimeout(500);

  for (const [route, budget] of Object.entries(BUDGETS)) {
    const hit = samples.find((s) => s.route === route);
    expect(hit, `no transition_lcp captured for ${route}`).toBeTruthy();
    expect(hit.value, `${route} transition LCP ${hit.value}ms`).toBeLessThan(budget);
  }
});

Run the flow under a throttled CPU and network profile so the CI numbers approximate mid-range mobile rather than a fast runner; matching the emulation you use elsewhere keeps the gate honest and comparable to the field. Report the captured value into the same store as your other route budgets so the Single-Page App Performance Budgets dashboard shows first-load and transition LCP side by side.

Verifying Against Field Data

Synthetic gates and lab approximations drift from reality, so close the loop by comparing your CI numbers against the field beacons you collect in production. Bucket the beaconed transition_lcp values by route, compute the P75 and P90 per bucket, and check that the CI budget sits at or above the field P75 with a small margin. If the field P75 for the detail route is 1720 ms on mid-range mobile over Fast 3G but your CI budget is 1400 ms, the gate will flap on noise; if the budget is 2400 ms, real regressions slip through. Aim to set each budget roughly 10 to 15 percent above the current healthy field P75 so it catches genuine drift without punishing normal variance.

Watch for three approximation errors when you reconcile. Cross-origin hero images without Timing-Allow-Origin report a zero renderTime, so annotate same-origin assets or add the header. Views that stream content in stages can promote a late-arriving element and inflate the value, so cap the settle window. And a route that paints from a warm cache on the second visit will look faster than a cold one, so segment by cache state before you trust the percentile — the same segmentation logic you would apply to any P75 real-world target.

Frequently Asked Questions

Why does the browser not report LCP after a client-side route change?

Largest Contentful Paint is tied to a single document lifecycle and stops updating at the first user interaction or when the page is hidden. A client-side route change reuses the same document via the History API, so no new largest-contentful-paint entry is emitted and every later view inherits the first load's value.

Is transition LCP the same number as native LCP?

No — it is an approximation built from Element Timing and the soft-navigation start, not the platform metric. It is directionally comparable because it also picks the largest painted element, but treat it as its own budgeted metric rather than a drop-in Core Web Vitals value.

What if the Soft Navigations API is not available in a browser?

Fall back to patching history.pushState and listening for popstate to reset the measurement window at each route change. It is less precise about the exact start time than the native entry, but it captures the vast majority of in-app navigations well enough to budget against.

How should I pick per-route transition-LCP budgets?

Derive each budget from the current healthy field P75 for that route on a named device and connection, then set the ceiling about 10 to 15 percent higher. Text-only routes deserve a tight budget near 900 ms on mid-range mobile over Fast 3G, while image-heavy routes need looser ceilings around 1800 ms.

Can I gate transition LCP in Lighthouse CI directly?

Not with a standard single-page run, because the metric only exists after an in-app navigation. Use a Playwright or Lighthouse user-flow script that drives the real router, reads back the beaconed value at each step, and fails the build when any route exceeds its budget.