In a single-page app users stay on one document for many minutes and change views client-side, and the app's CLS is poor even though the initial load produces almost no shifts. How does CLS accumulate across a session like that, and how would you attribute a shift to the view that caused it?
answer
- one document, one measurement
- pushState does not reset it
- worst burst anywhere in the session
- stamp the route yourself
- sources names the nodes that moved
basics
~20 sCLS keeps accruing for the whole document lifetime and is not reset by a client-side route change, so any view transition can define the score. Attribute shifts by logging each layout-shift entry with its value, timestamp, source nodes and the current route.
solid answer
~50 sCLS is a per-document metric, not a per-screen one. A `history.pushState()` route change does not reset it, so every shift the user provokes for the rest of a long session is a candidate, and the reported value is the worst five-second burst wherever it happened. That is why a clean initial load can coexist with a bad score: the damage is in a view transition halfway through the session. To attribute it, observe `layout-shift` entries and record, per entry, the `value`, `startTime`, `hadRecentInput`, and the nodes in `sources` — plus your app's current route at that moment — then report on `visibilitychange` when the page is hidden. The `attribution` build of Google's `web-vitals` library does most of this for you. Watch for the blind spot too: shifts within 500 ms of a click are exempt, so a fast view swap is forgiven while a slow one that lands a second later is scored in full.
code
javascript · 22 lineslet route = location.pathname;
export function setRoute(next) { route = next; }
const shifts = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue;
shifts.push({
value: entry.value,
at: Math.round(entry.startTime),
route,
nodes: entry.sources.map((s) => s.node && s.node.tagName),
});
}
}).observe({ type: 'layout-shift', buffered: true });
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && shifts.length) {
navigator.sendBeacon('/rum/cls', JSON.stringify(shifts));
shifts.length = 0;
}
});go deeper
Know that in a single-page app the measurement does not start over when the view changes — it belongs to the document, so shifts long after load still count.
Explain that a client-side route change leaves CLS accruing, and that the reported value is the worst burst of shifts anywhere in the session rather than a total.
Demonstrate the attribution workflow: observe layout-shift entries, keep the value, timestamp and source nodes, stamp your current route, and report when the page is hidden using a beacon. Know that the 500 ms exemption hides fast transitions and not slow ones.
Own the monitoring design: decide how per-route attribution is captured across teams, what identifier represents a component in field data, and how a single session-level number is turned into an owner and a ticket rather than a dashboard nobody can act on.
## CLS belongs to the document, not the screen The metric accrues for as long as the document is alive. Nothing about a client-side navigation — `history.pushState()`, a router swapping components, a URL change with no request — ends the measurement or starts a new one. As far as the platform is concerned, a forty-minute session across twelve views is **one page**. For a multi-page site this distinction barely matters, because a real navigation creates a new document and a new measurement. For a single-page app it is the whole story: your initial load can be flawless and your score still poor, because the score is being set by something the user triggered twenty minutes in. ## What the session window does and does not soften Session windows keep this from being unbounded. Shifts group into windows that close after a one-second gap and cap at five seconds, and CLS reports the **largest** window. So a long session does not mechanically accumulate a worse score than a short one — only the worst single burst counts. The consequence is worth stating plainly: **one bad view transition defines the session**. If switching to the reports tab reflows the page badly, it does not matter that the other eleven views are perfect, and it does not matter whether the user visited that tab once or ten times. The headline number cannot distinguish those cases; only your own logging can. ## The 500 ms exemption is the blind spot Shifts within 500 ms of a discrete input — click, tap, key press — carry `hadRecentInput` and are excluded. In an SPA this cuts in an odd direction. A view transition that renders immediately after the click is forgiven, as it should be. But a transition that fetches data and lands 900 ms later is well outside the window and is scored in full, even though the user clicked and is expecting a change. The metric cannot tell "the user asked for this" from "content shoved itself in" once the causal link is that old. So the fastest transitions are also the ones the metric treats most generously — which happens to align incentives correctly, but it also means users will report jumpiness that your dashboard does not show, and vice versa. When users complain about movement your data says is clean, check whether those shifts are landing inside the exemption. ## Attribution: from a number to a component A session-level score is unactionable on its own. Collect the individual entries instead: ```js let route = location.pathname; // update this from your router const shifts = []; new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.hadRecentInput) continue; shifts.push({ value: entry.value, at: Math.round(entry.startTime), route, nodes: entry.sources.map((s) => s.node && s.node.tagName), }); } }).observe({ type: 'layout-shift', buffered: true }); ``` The pieces that matter: - **`sources`** — each entry lists the nodes that moved, with `previousRect` and `currentRect`. In the field you cannot ship DOM nodes to a server, so map them to something stable: a selector, a `data-component` attribute, or the component name from your own registry. - **`startTime`** — lets you reconstruct which shifts shared a session window and therefore which ones actually contributed to the reported value. - **your route at that moment** — the platform does not know your app has views, so you have to stamp it yourself, from the router, at the time the entry arrives. - **`buffered: true`** — picks up shifts that occurred before your observer registered, which matters for anything during startup. Google's `web-vitals` library ships an `attribution` build that packages most of this, giving you the largest shift source and the load state alongside the value. Use it rather than hand-rolling unless you need the per-route stamping it does not know about. ## Reporting from a long-lived page A session that never navigates never fires an unload you can rely on. Report on `visibilitychange` when `document.visibilityState` becomes `hidden` — that is the only event you can count on to fire on mobile — and send with `navigator.sendBeacon()` so the request survives the page being backgrounded. Sending only at the end also means the value you send is final; sending incrementally means you have to reconcile updates server-side, because CLS can rise at any moment. ## What to do with the answer Once shifts are attributed per route, the fixes are ordinary: the view whose skeleton is a different height from its real content, the panel that expands after its data arrives, the list that re-measures. The point of the exercise is that in an SPA nothing else will point you at them — the score is a single number covering a session that may have touched a dozen screens, and without per-shift logging you are guessing which one to open.
- A user clicks a tab and the new view renders 900 ms later, shifting the page. Is that shift counted?Yes. The exemption only covers shifts within 500 ms of a discrete input, and this one lands well outside it. The metric cannot infer that the click a second earlier caused it. The practical implication is that slow transitions are penalised twice — once by the user waiting, once by the score — so making the swap fast is itself a CLS fix.
- Why does a long session not automatically score worse than a short one?Because CLS reports the largest session window rather than a lifetime sum. Windows close after a one-second gap and cap at five seconds, so shifts spread across a session never combine. That was the point of moving away from the original lifetime-sum definition, which penalised pages simply for being kept open.
- You cannot ship DOM nodes to your analytics backend. How do you make the sources array useful in the field?Map each source node to a stable identifier at collection time — a `data-component` attribute, a short CSS selector path, or a name from your own component registry — and send that string instead of the node. Include the previous and current rectangles if you want to know direction and distance, since those are plain numbers and survive serialisation.
saying these in an interview costs you the question
- Thinks CLS resets on a client-side route change
- Assumes the initial load is the only thing measured
- Believes shifts after any click are always exempt
- Reports the score without attributing it to a view
- Relies on unload to send field data from a long session