Why Your SPA's CLS Gets Worse the Longer Users Stay — and How to Attribute It

This guide is part of Soft Navigations & SPA Metrics in Core Web Vitals & Measurement. The symptom: CLS for your landing pages is above 0.1 at p75 in the field, but loading those pages in the lab produces almost no shift. Sessions with more page views have worse CLS. Bounced sessions look fine.

The cause is how Cumulative Layout Shift is defined. CLS is not the sum of all shifts; it is the largest session window — a burst of shifts each less than one second apart, capped at five seconds. In a multi-page site, each document has a short life and few windows. In an SPA, one document lives for the entire session and every route contributes windows. The worst of them, from any route, becomes the session's CLS, filed against the landing URL.

Session windows across three routes Timeline of layout shifts across three SPA routes grouped into session windows, with the largest window occurring on the third route but charged to the landing page. Session windows across three routes Routes /home /search /item/:id Windows 0.02 0.04 0.19 0s 15s 30s 45s 60s 75s 90s CLS for this session is 0.19, the largest window, and it is reported against /home.

Rapid Diagnosis

  • Plot CLS against pages per session. A rising curve means in-app routes are contributing shifts.
  • Log shift timestamps with the active route. In a test session, record each layout-shift entry's startTime, value and the route at that time. The heavy window is usually on one route type.
  • Inspect entry.sources. Each shift lists up to five affected nodes with previousRect and currentRect; selectors from a route-specific component confirm where it came from.
  • Watch for shifts right after navigation. Many SPA shifts happen within the first second of a route render: content replacing a skeleton of the wrong height, or an image without dimensions.

Root Cause Analysis

1. One document, many windows. The windowing rules are applied across the whole document lifetime. More routes means more opportunities for a bad window, so CLS grows with engagement even when no single route is unusually bad.

2. Route renders that look like late content. When a route's content arrives after data loads, it replaces a placeholder. If the placeholder height differs, the content below moves — and because the navigation click was more than 500ms earlier, the shift does not count as input-driven.

3. The 500ms input exclusion is too short for SPAs. Shifts within 500ms of a discrete input are excluded (hadRecentInput). A route transition that takes 800ms to load data falls outside this window, so its render shifts all count.

4. Persistent shell elements reacting to route changes. A header that changes height on certain routes, or a banner that appears on product pages, shifts everything below it on each navigation.

Why route render shifts are not excused by the click Timeline showing the 500 millisecond input exclusion window after a click ending before route content arrives and shifts the layout. Why route render shifts are not excused by the click Input shifts excluded Route chunk + data shift 0ms 200ms 400ms 600ms 800ms 1000ms 1200ms 500ms exclusion ends

Step-by-Step Resolution

1. Attribute every shift to the route that was active

javascript
import { routeAt } from './route-history.js';
const shiftsByRoute = new Map();
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (e.hadRecentInput) continue;
    const route = routeAt(e.startTime);
    const arr = shiftsByRoute.get(route) || [];
    arr.push({ t: e.startTime, v: e.value,
      node: e.sources?.[0]?.node?.closest('[data-component]')?.dataset.component });
    shiftsByRoute.set(route, arr);
  }
}).observe({ type: 'layout-shift', buffered: true });
// trade-off: keeping every shift in memory is fine for typical sessions but
// grows without bound in apps left open for hours. Fold shifts into window
// maxima as they arrive (step 2) if sessions are long.

Expected outcome: a per-route list of shifts with a responsible component name.

2. Compute the CLS window maximum per route

Apply the official windowing rules within each route's shifts, starting fresh at each navigation.

javascript
function windowedCLS(shifts) {
  let max = 0, cur = 0, first = 0, prev = 0;
  for (const s of shifts) {
    if (cur && (s.t - prev > 1000 || s.t - first > 5000)) { max = Math.max(max, cur); cur = 0; }
    if (!cur) first = s.t;
    cur += s.v; prev = s.t;
  }
  return Math.max(max, cur);
}
const perRouteCLS = [...shiftsByRoute].map(([route, s]) => ({ route, cls: windowedCLS(s) }));
// trade-off: splitting windows at route boundaries means a burst that straddles
// a navigation is counted as two smaller windows. That understates CLS slightly
// compared with the official document-level value; document it on dashboards.

Expected outcome: per-route CLS values that use the same rules as the official metric and add up to an explanation of the session value.

3. Reserve space for route content before data arrives

Most SPA route shifts come from placeholders with the wrong size. Give skeletons the real content's dimensions — fixed heights for cards, aspect-ratio for media — or render the route's layout frame from known metadata before data arrives. Fixing CLS from skeleton screens that resize covers the techniques in detail.

css
.product-hero { aspect-ratio: 1 / 1; }                 /* image area never collapses */
.product-meta { min-height: 7.5rem; }                   /* title + price + rating */
.related-rail { min-height: 22rem; contain: layout; }   /* async rail cannot push the footer */
/* trade-off: min-height reserves space even when content is shorter, leaving
   gaps on sparse products. Measure the p75 content height, not the maximum. */

Expected outcome: route-level CLS for templated views drops below 0.05 in most cases.

4. Keep the persistent shell stable across routes

Give headers, banners and navigation a fixed height on every route, or animate their height changes with transform rather than layout properties. A promo bar that appears only on product routes should occupy a reserved slot everywhere, even if empty.

Expected outcome: shifts attributed to shell components disappear from the per-route report.

Per-route CLS before and after placeholder fixes Bar chart of p75 CLS per route showing the item route dropping below the 0.1 threshold after reserving space. Per-route CLS before and after placeholder fixes /item/:id before 0.19 /item/:id after 0.03 /search before 0.06 /search after 0.02 CLS 0.1

Verification

Run a scripted session that visits five routes and assert that no route's windowed CLS exceeds 0.05 in the lab. In the field, chart CLS against pages-per-session again: after the fix the curve should flatten, because more routes no longer means more chances for a bad window. The landing page's official CLS in CrUX should fall with it, typically over the 28-day CrUX window.

Patterns That Generate Shifts Only in SPAs

Some layout shifts are unique to client-side routing and never appear in a multi-page version of the same site, which is why they survive lab testing that loads each URL directly.

Scroll restoration racing the render. Routers restore the previous scroll position on back navigation. If the restored position is applied before the route's content has its full height, the browser clamps the scroll, then the content grows and the router re-applies it. Each correction moves visible content. Restore scroll only after the route's data has rendered, or reserve the height first.

Keyed list transitions. Animating list items in and out with <TransitionGroup> or Framer Motion's layout animations can move siblings using layout properties. When the animation outlives the 500ms input exclusion, every frame of it counts. Prefer transform-based FLIP animations, which shift visually without moving layout boxes.

Lazy route chunks with their own CSS. A route whose CSS ships in its own chunk can render one frame with unstyled or partially styled content if the stylesheet arrives after the component. In Vite and webpack this shows up as a flash of large, unstyled text that then collapses. Ensure route CSS is loaded before the route component renders — most bundlers' route-level CSS injection does this, but manual import() of a component outside the router may not.

Persisted layout state. A sidebar that remembers "collapsed" in localStorage renders expanded on server-rendered landing pages and collapsed after hydration. In an SPA this only happens once per session, but on the landing route, where CrUX attributes it.

FAQ

Does the official CLS ever reset on soft navigations?

Not in the standard definition. The experimental Soft Navigations API in Chromium proposes resetting metrics at detected soft navigations, but it is not what CrUX uses today. Your own per-route attribution is what makes the data actionable; the official number will improve as a consequence of fixing the routes it reveals.

Should I count shifts that happen during route transitions at all?

Yes. Users see them, and they are real layout instability. The point of per-route attribution is not to exclude them but to charge them to the route whose rendering caused them, so the owning team sees and fixes them.