Almost every hand-rolled Cumulative Layout Shift implementation is wrong in the same way: it adds up every layout-shift entry it receives. The specification does not, and the difference is large enough to change whether a page passes.
Problem / Scenario Context
A team instruments CLS, reports it to their own dashboard, and finds their number is roughly three times what Chrome's field data says for the same pages. Nothing is obviously broken — the entries are real, the values are real, the arithmetic is a correct sum.
The sum is the problem. CLS is defined as the largest session window, not the total. A page that shifts a little every time the reader scrolls into a new section accumulates a steady trickle of small shifts that the specification deliberately does not add together, because a page that is stable for ten seconds and then shifts slightly is not three times worse than one that shifts once.
The second common error is the opposite: filtering out too much. Shifts within 500 ms of a user interaction are excluded because the reader caused them — but a page that fires synthetic input events during startup can suppress genuine load shifts and report a suspiciously perfect zero.
Mechanics Explanation
Each layout-shift entry carries three things worth reading: a value (the impact fraction times the distance fraction), a hadRecentInput boolean, and a sources array naming the nodes that moved with their previous and current rectangles.
The score is computed by grouping entries into session windows. A window continues while each new entry is within 1000 ms of the previous entry and within 5000 ms of the window's first entry. Anything outside those bounds starts a fresh window. The reported CLS is the largest window's total.
That definition is what makes the metric describe an episode of instability rather than a lifetime sum, and it is why a long-lived single-page application does not automatically fail.
Comparison Table: Sum vs. Windowed
| Page behaviour | Naive sum | Windowed score | Which is right |
|---|---|---|---|
| One bad shift at load | same | same | either |
| Many tiny shifts over 5 minutes | large and growing | small | windowed |
| Two bad episodes far apart | added together | the worse one | windowed |
| Long single-page session | grows forever | bounded | windowed |
| Shift 200 ms after a click | counted | excluded | windowed |
Minimal Reproducible Example
// The common wrong implementation
let cls = 0;
new PerformanceObserver((list) => {
for (const e of list.getEntries()) cls += (e as any).value; // no windows, no input filter
}).observe({ type: 'layout-shift', buffered: true });
Production-Safe Solution
interface ShiftEntry extends PerformanceEntry {
value: number;
hadRecentInput: boolean;
sources: Array<{ node?: Node; previousRect: DOMRectReadOnly; currentRect: DOMRectReadOnly }>;
}
let windowValue = 0;
let windowEntries: ShiftEntry[] = [];
let cls = 0;
const offenders = new Map<string, number>();
new PerformanceObserver((list) => {
for (const raw of list.getEntries()) {
const entry = raw as ShiftEntry;
if (entry.hadRecentInput) continue; // the reader caused it
const first = windowEntries[0];
const last = windowEntries[windowEntries.length - 1];
const inWindow = windowEntries.length > 0
&& entry.startTime - last.startTime < 1000
&& entry.startTime - first.startTime < 5000;
if (inWindow) { windowValue += entry.value; windowEntries.push(entry); }
else { windowValue = entry.value; windowEntries = [entry]; }
if (windowValue > cls) cls = windowValue;
// attribution: which node moved, and by how much in total
for (const src of entry.sources ?? []) {
const el = src.node instanceof Element ? src.node : null;
if (!el) continue;
const key = describe(el);
offenders.set(key, (offenders.get(key) ?? 0) + entry.value);
}
}
}).observe({ type: 'layout-shift', buffered: true });
function describe(el: Element): string {
const id = el.id ? `#${el.id}` : '';
const cls0 = typeof el.className === 'string' && el.className.trim()
? `.${el.className.trim().split(/\s+/)[0]}` : '';
return `${el.tagName.toLowerCase()}${id}${cls0}`;
}
addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden') return;
const worst = [...offenders.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3);
navigator.sendBeacon('/vitals', JSON.stringify({
cls: Number(cls.toFixed(4)),
worstShifters: worst.map(([sel, v]) => ({ sel, v: Number(v.toFixed(4)) })),
path: location.pathname,
}));
}, { once: true });
The offenders map is what makes the number actionable. A CLS of 0.18 tells you there is a problem; img.hero-media accounting for 0.15 of it tells you which line to change — almost always a missing width/height pair or a missing aspect-ratio reservation.
Verification Steps
- Compare against Chrome's own number in the Performance panel's Web Vitals lane on the same load; a large gap means the windowing is wrong.
- Assert the window logic directly with a unit test that feeds synthetic entries at controlled timestamps and checks the resulting maximum.
- Confirm the input filter is not over-firing by logging how many entries were skipped for
hadRecentInput; a page reporting zero shifts and skipping dozens is suppressing real ones. - Verify attribution by outlining the reported worst offender during a manual load:
document.querySelector(sel).style.outline = '2px solid red'.
Common Mistakes to Avoid
- Summing every entry. Produces a number that only grows and does not match any published threshold.
- Reading
entry.element. That field does not exist on a layout-shift entry; the nodes are insources. - Ignoring
hadRecentInput. Reader-initiated reflows — opening an accordion, expanding a menu — are not defects and are excluded by definition. - Assuming a route change resets it. CLS is per-document. A soft navigation that shifts content adds to the same score, which is one more reason to be careful with observer-driven layout writes.
FAQ
Why is my CLS higher than the one Chrome reports?
Almost certainly because you are summing every entry rather than taking the largest session window. The specification groups entries into windows that end after a one-second gap or a five-second span, and reports the worst window — a definition that deliberately does not punish a page for being long-lived.
What does hadRecentInput actually exclude?
Shifts that occurred within roughly half a second of a user interaction, on the basis that the reader caused them. Opening a menu or expanding an accordion moves content legitimately. Excluding these is required for the number to mean anything, but a page that dispatches synthetic input events early can suppress genuine load shifts by accident.
How do I find out which element moved?
Read the sources array on the entry. Each source has a node reference plus its previous and current rectangles, so you can both name the element and see how far it travelled. There is no element field on a layout-shift entry, which is a common source of undefined.
Does CLS reset when a single-page app changes route?
No. The metric is scoped to the document, so soft navigations accumulate into the same score. Content that shifts during a route transition counts, which makes reserved space just as important on client-rendered views as on the initial load.
Related
- Measuring LCP with PerformanceObserver — the other paint metric lazy loading affects
- PerformanceObserver buffered Entries Explained — why load-time shifts go missing without the flag
- Lazy Loading Images & Media — reserving space so deferred media shifts nothing
↑ Back to PerformanceObserver & Rendering Metrics