Skip to content
Issue docs

Cumulative Layout Shift in the poor range

Importantpoor_clsIssue 137

What is this issue?

Cumulative Layout Shift (CLS) measures how much the content of a page moves around on its own while somebody is reading it -- the paragraph that jumps down when an image finally loads, the button that slides sideways as a banner appears.

It is a score rather than a duration: how far things moved, multiplied by how much of the screen they took up, for the worst burst of movement during the visit.

Google puts every page into one of three bands:

CLS Verdict
0.1 or less Good
0.1 to 0.25 Needs improvement
more than 0.25 Poor

This check reports pages in the poor band.

Movement a visitor asked for does not count. A panel that opens because somebody tapped it, or a page that scrolls because somebody scrolled, is excluded -- only shifts the visitor did not cause are measured.

For a page to pass, everything that is going to appear should have its space reserved before it arrives.

Why it matters

  • It is a ranking signal. CLS is one of the three Core Web Vitals feeding Google's page-experience signals, and Search Console reports it back to you per URL group.
  • It causes the wrong tap. The classic failure is a visitor pressing "Cancel" because "Confirm" moved under their thumb a frame earlier. On a checkout or a form, that is a lost order rather than an annoyance.
  • It makes reading feel unreliable. Text that jumps mid-sentence forces the reader to find their place again. People do not report this as a bug; they leave.
  • It is one of the cheapest vitals to fix. Most of it is width and height attributes on images and a reserved box for whatever loads late. The fix is usually markup, not architecture.

Fixing it moves the page out of the poor band and removes an important-severity finding from the site's health score.

How to fix it

  1. Give every image and video a width and a height. With both set, the browser reserves the right box before the file arrives and nothing below it moves. This alone fixes most poor CLS scores.

  2. Reserve space for anything that loads late. Ads, embeds, iframes, cookie banners, personalised blocks and lazy-loaded sections all need a placeholder of the right size, not an empty container that grows.

  3. Never insert content above what is already on screen. A notification bar or a promo strip added after the page has rendered pushes the entire page down. If it must appear late, overlay it or leave a slot for it.

  4. Load fonts so the swap does not reflow. Use font-display: optional or swap with a fallback whose metrics are close to the web font, and preload the font file. A fallback of a very different size reflows every paragraph.

  5. Animate transforms, not layout. transform and opacity do not move anything else on the page; animating top, height or margin does.

  6. Set explicit sizes on anything driven by data. A star rating, a price, a stock badge or a countdown that arrives from an API needs a box sized for its longest value.

  7. Check the whole visit, not just the load. CLS accumulates while the page is open, so an infinite scroll or a late-arriving recommendation strip counts too.

Examples

1. Images with no dimensions

An article template rendered images without width or height, so every paragraph below one jumped down the moment the file arrived.

Fails -- CLS 0.41

<img src="/diagram.png" alt="How the pipeline fits together">

Passes -- CLS 0.02

<img src="/diagram.png" alt="How the pipeline fits together"
     width="1200" height="675">
img { max-width: 100%; height: auto; }  /* keeps it responsive */

The attributes give the browser the aspect ratio; the CSS keeps it fluid.

2. A banner inserted above the content

A cookie notice was appended to the top of the body after the page had rendered, pushing everything down by its own height.

Fails -- CLS 0.33

document.body.prepend(cookieBanner);  // after first paint

Passes -- CLS 0.01

.cookie-banner {
  position: fixed;
  inset-inline: 0;
  bottom: 0;                 /* overlays, moves nothing */
}

3. A late price, in a box sized for nothing

A product page rendered an empty span and filled it from an API, so the layout reflowed when the price arrived.

Fails -- CLS 0.28

<span class="price"></span>

Passes -- CLS 0.00

<span class="price" aria-busy="true">&nbsp;</span>
.price { display: inline-block; min-width: 6ch; min-height: 1.5rem; }

Reserving the space for the longest plausible value costs nothing and moves nothing.

How PixyScan detects this

  1. Chooses which pages to measure. After the crawl, PixyScan measures the homepage plus the pages your own site links to most, up to a per-scan budget (ten pages by default).

  2. Asks the PageSpeed Insights API about each of them, on mobile.

  3. Prefers real-user data. Where the Chrome UX Report has enough traffic for that exact URL, CLS is the 75th percentile of what real Chrome users experienced over the last 28 days -- which includes shifts caused by scrolling through the whole page, not just by the initial load. Where it does not, the CLS from the PageSpeed lab run is used instead, and the report says which of the two it used.

  4. Ignores origin-level data for a page verdict. When the report has too little data for the URL, PageSpeed answers with the whole site's distribution. That is a statement about the site, not this page, so it is not used here.

  5. Raises the issue only above 0.25 -- Google's own "poor" bound. A page at exactly 0.25 is "needs improvement" and raises nothing.

  6. Raises nothing on a page it did not measure. A page outside the sample, and a page whose measurement failed, are both reported as Not measured. Neither is treated as passing.

A measured CLS of zero is a real result and is reported as 0.00, not as Not measured: it means the page was checked and nothing moved.

What we store

Storage Level

Page Level


Database Table / Prisma Model

PagePerformance (page_performance), with the finding itself on audit_issues.details.


Fields Used

Field Type Description
strategy String mobile or desktop. Every number in the row belongs to one form factor.
fetchedAt DateTime When PageSpeed answered. "Measured three weeks ago" is not "not measured".
errorReason String? Why a sampled page still has no numbers. Null when the call succeeded.
performanceScore Int? Lighthouse's performance category, rescaled 0-100.
labCls Float? CLS from the PageSpeed lab run. Unitless.
fieldCls Float? CLS from the Chrome UX Report for this URL. Null when the report has no data for it.

On the finding (audit_issues.details): metric, source (field or lab), value and threshold (0.25). CLS is unitless, so the finding carries value rather than valueMs.


Detection Dependencies

  • PageSpeed Insights API v5 (lighthouseResult)
  • Chrome UX Report field data (loadingExperience), where the URL has the traffic
  • The crawl's internal link graph, which decides which pages are sampled

Note

A page with no row was never measured. The pass samples the homepage plus the most-linked pages within a per-scan budget (PERFORMANCE_SAMPLE_LIMIT, default 10), so most pages of a site have no page_performance row at all. That is the normal state, it is drawn as Not measured, and this check cannot be raised against it.

A row whose metric columns are all null with an errorReason set is a different state again: the page WAS sampled and the measurement failed. It is still never reported as passing.

A stored CLS of 0 is neither of those. It is a measurement, it is reported as 0.00, and it means the page was checked and nothing moved. The Chrome UX Report sends CLS as an integer in hundredths (12 meaning 0.12); PixyScan converts it on the way in, so what is stored is always the real unitless score.

Further reading