Fixing CLS from Lazy-Loaded Images

Lazy loading trades bytes for risk: an image that arrives after layout has settled will move everything below it unless the space was reserved first. This guide sits under CLS Reduction Strategies and covers the three configurations that produce shifting images, the markup that prevents each one, and how to confirm in field data that the shifts are actually gone rather than pushed below the fold.

The reason this stays a live problem long after “always set width and height” became common advice: modern layouts override those attributes in CSS, image pipelines strip them, and component libraries render <img> without them by default. The advice is right and the markup that reaches production frequently is not.

Prerequisites

  • Layout shift attribution in the field, so you can see which element sources the shift — the setup is in Debugging Web Vitals with the Attribution Build.
  • A list of every image on the route and whether it is above or below the fold at each breakpoint.
  • The ability to change image markup at the component level, not just per page.

How to eliminate the shift

Step 1 — Confirm images are the source

// Report the largest shift source per session, with enough detail to fix it.
import { onCLS } from 'web-vitals/attribution';

onCLS((metric) => {
  const a = metric.attribution;
  navigator.sendBeacon('/rum', JSON.stringify({
    metric: 'CLS',
    value: Math.round(metric.value * 1000) / 1000,
    // largestShiftTarget is a selector for the element that moved most.
    source: a.largestShiftTarget,
    shiftValue: Math.round(a.largestShiftValue * 1000) / 1000,
    at: Math.round(a.largestShiftTime),
    route: window.__route,
  }));
});

Group by source and rank by summed shiftValue. If image selectors dominate, this guide applies; if the top sources are ad slots or injected banners, the fixes are different and covered in Reducing CLS from Dynamic Ad Injections and Eliminating CLS from Cookie Consent Banners.

Shift score attributed to each image positionBody images dominate because they sit above the most content — impact fraction is about how much of the viewport moves, not about the image size.Shift score attributed to each image positionSum of layout shift values by element, 30 daysArticle body images0.08Related-content thumbnails0.04Author avatar0.01Footer logos000.030.050.080.1
Body images dominate because they sit above the most content — impact fraction is about how much of the viewport moves, not about the image size.

Step 2 — Reserve the box before the bytes arrive

Three mechanisms, in order of preference. All three work; the difference is how they survive contact with a stylesheet.

<!-- 1. Intrinsic dimensions: the browser derives an aspect ratio from the
        attributes and reserves the box before the file arrives. -->
<img src="/photo.webp" width="1200" height="800" alt="" loading="lazy" decoding="async">

<!-- 2. Explicit aspect-ratio: survives CSS that sets width:100%; height:auto. -->
<style>
  .post-image { width: 100%; height: auto; aspect-ratio: 3 / 2; }
</style>
<img class="post-image" src="/photo.webp" width="1200" height="800" alt="" loading="lazy">

<!-- 3. Wrapper with padding-top: needed only for legacy engines or when the
        ratio varies per breakpoint and cannot be expressed on the element. -->
<div class="ratio-box" style="--ratio: 66.67%"><img src="/photo.webp" alt="" loading="lazy"></div>

The failure that survives review most often is a stylesheet containing img { height: auto; } with no aspect-ratio. That single rule discards the intrinsic ratio the attributes provided, and the reserved box collapses to zero height until the file decodes.

Step 3 — Do not lazy-load anything above the fold

loading="lazy" on an in-viewport image delays it past first layout and guarantees both a shift and a worse LCP. The rule is mechanical: images in the initial viewport at any breakpoint are eager, everything else is lazy.

// Build-time or server-side: mark only what is genuinely below the fold.
// A viewport-height heuristic is enough; precision is not required.
function loadingAttr(index, breakpoint) {
  const aboveFoldCount = breakpoint === 'mobile' ? 1 : 3;
  return index < aboveFoldCount ? 'eager' : 'lazy';
}

Because the fold moves between breakpoints, the safe default is to treat the first image as eager everywhere and add fetchpriority="high" to it if it is the LCP element, as covered in Optimizing LCP with fetchpriority & preload.

Step 4 — Watch the loading threshold, not just the attribute

Native lazy loading starts fetching well before the image enters the viewport, and the distance depends on connection quality. A custom IntersectionObserver implementation with rootMargin: "0px" starts the fetch exactly as the image scrolls in, which is far too late — the placeholder is visible, the image decodes a moment later, and if the box was not reserved the content jumps.

// If you must roll your own, start early and reserve space regardless.
const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    if (!e.isIntersecting) continue;
    const img = e.target;
    img.src = img.dataset.src;
    io.unobserve(img);
  }
}, { rootMargin: '600px 0px' });   // start ~1.5 viewports early, not at the edge

document.querySelectorAll('img[data-src]').forEach((img) => io.observe(img));

Step 5 — Handle the images whose ratio you do not know

User-uploaded content is the hard case: the markup is generated before anyone knows the dimensions. Three workable answers, in order of quality: store dimensions at upload time and emit them; ask the image CDN for metadata at render time; or impose a fixed aspect ratio with object-fit: cover so the box is always correct even when the image is not.

/* Uniform box, variable content. No shift, at the cost of cropping. */
.ugc-image {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  background: var(--bg-alt);   /* visible while loading; no jump when it is not */
}
p75 CLS through the rolloutDimension attributes shipped on day 4 and the aspect-ratio CSS fix on day 6. The residual 0.024 is the web font swap, which is a separate fix.p75 CLS through the rolloutArticle template, daily p7500.050.10.150.2Good 0.1d1d2d3d4d5d6d7d8p75 CLS
Dimension attributes shipped on day 4 and the aspect-ratio CSS fix on day 6. The residual 0.024 is the web font swap, which is a separate fix.

Verifying it works

  1. Attribution should stop naming image selectors in the top shift sources. If the same selector persists, the reserved box is not being applied at that breakpoint.
  2. p75 CLS should drop on the route, and the drop should be visible within a day or two — CLS reacts faster than LCP because it is measured on every load rather than depending on a cache state.
  3. Check the shift did not move below the fold. Shifts outside the viewport score zero impact fraction, so a “fix” that pushed content down can look like a win in the metric while the experience is unchanged.
  4. Confirm at every breakpoint. A layout that reserves space correctly at desktop and collapses at 390 px is the most common partial fix.

Auditing every image on a route in one pass

Before shipping component-level changes it is worth knowing the size of the problem. This console snippet lists every image on the page with the two properties that decide whether it can shift, and it takes seconds to run at each breakpoint.

console.table([...document.images].map((img) => {
  const cs = getComputedStyle(img);
  const rect = img.getBoundingClientRect();
  return {
    src: img.currentSrc.split('/').pop().slice(0, 32),
    hasAttrs: Boolean(img.getAttribute('width') && img.getAttribute('height')),
    aspectRatio: cs.aspectRatio,
    loading: img.loading,
    aboveFold: rect.top < innerHeight,
    reserved: rect.height > 0,
  };
}));

Any row where hasAttrs is false and aspectRatio is auto is a shift waiting for a slow connection. Any row where aboveFold is true and loading is lazy is both a shift risk and an LCP regression. Running this at 390 px, 768 px and 1280 px catches the breakpoint-specific cases that a single desktop audit misses entirely.

Which markup actually reserves the boxOnly the third row is safe in every case, which is why the advice is both rather than either.Which markup actually reserves the boxWhat survives contact with a typical stylesheetReserves spaceSurvives height:autoPreload scanner sees itwidth + height attributesYesNoYesaspect-ratio in CSSYesYesNoBoth togetherYesYesYesNeitherNoNoNo
Only the third row is safe in every case, which is why the advice is both rather than either.

Edge cases and gotchas

  • height: auto without aspect-ratio discards the intrinsic ratio the attributes gave you. This single CSS rule is responsible for a large share of image-sourced CLS.
  • Responsive images with different ratios per breakpoint need aspect-ratio inside the matching media query, or the reserved box is wrong at one of them.
  • Images inside <picture> derive their ratio from the <img> element, not the <source>. Put width and height on the img.
  • Print stylesheets and email templates frequently reset image dimensions. Irrelevant to CLS, but a common source of confusion when auditing the CSS.
  • Shifts after user input do not count. A layout shift within 500 ms of an interaction is excluded via hadRecentInput, which is why a shift you can reproduce by clicking may never appear in the metric.
  • Placeholder colour changes are not shifts. Swapping a background colour for the decoded image does not move anything, so a coloured placeholder is free — use it.

FAQ

Does loading="lazy" cause CLS by itself?

No — an unreserved box does. Lazy loading makes the unreserved box more likely to be visible, because the image arrives after layout has already settled around a zero-height element.

Should I lazy-load every image below the fold?

Almost always yes, with one caveat: images just below the fold on a short page will be requested almost immediately anyway, so the saving is small while the risk of a shift is the same. Reserve the space and the question stops mattering.

Is aspect-ratio enough on its own?

It is, when it matches the actual image. Keep the width and height attributes anyway — they are available to the preload scanner before your stylesheet has loaded, which is exactly the window the shift happens in.

Do decorative background images need the same treatment?

A CSS background does not affect layout, so it cannot shift anything by itself — but the element it sits on usually has content-dependent height. Give that element an explicit height or aspect ratio and the question disappears.

The carousel container should reserve the full slide height from the first render. Sliders that size themselves to the first loaded slide produce a shift on every initialisation, and a second one when the fonts inside the caption swap.

Why does my CLS look fine locally but bad in the field?

Because you have a warm cache and a fast connection, so images decode before layout settles. The shift only appears when the image arrives late, which is the normal case on a real network.