Skip to content

An Astro Image Pipeline That Fixes CLS, Not File Size

A full Astro and React image pipeline using astro:assets and Sharp, covering AVIF generation, responsive sizes, and eliminating layout shift.

· · 10 min read
Assorted framed pictures on a wall

Quick Take

I used to think image optimization meant a smaller file. It doesn't mean much if the image still shifts the layout while it loads. Fixing both at once needed a real pipeline, not just a format swap.

Image optimization is two independent jobs wearing one name: shrinking the bytes that travel, and reserving the layout space before those bytes arrive. A 40KB AVIF image that loads without dimensions still causes a layout shift exactly like a 400KB JPEG would. I learned this rebuilding an article page pipeline: format conversion alone fixed the network tab, it did nothing for the CLS score, because that's a completely different problem with a completely different fix.

Quick take: A real image pipeline handles two separate problems: file size (AVIF/WebP generation with astro:assets and Sharp) and layout stability (explicit width/height so the browser reserves space before the image loads). Use fetchpriority="high" and eager loading on your LCP image only, lazy loading on everything else. Getting AVIF right without also fixing CLS solves half the problem.

Two words get used interchangeably in image-performance threads and mean completely different things:

  • Format optimization is re-encoding the same picture into AVIF or WebP so fewer bytes travel. It changes load time and nothing else.
  • Layout stability is telling the browser how much space to reserve before the bytes arrive. It changes CLS and nothing else.
  • LCP element is whichever above-the-fold element paints largest, which on an article page is almost always the hero image.

Work through a page in this order, because each step makes the next one measurable:

  1. Add explicit width and height to every image. This is the CLS fix and it needs no build tooling.
  2. Identify the LCP element and give it loading="eager" plus fetchpriority="high".
  3. Convert formats to AVIF with a WebP fallback, which is where the byte savings live.
  4. Re-measure. If CLS moved when you changed formats, something else on the page is shifting.
Extreme close-up of a camera lens aperture and iris blades
Photo by Jefferson Sees on Unsplash

Step 1: How does astro:assets generate AVIF and WebP?

The astro:assets module handles AVIF and WebP generation plus responsive sizing on its own, calling Sharp under the hood, so a project gets a full image pipeline without adding a build tool or a CDN. One component call replaces what used to be a Gulp task:

---
import { Image } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
---

<Image
  src={heroImage}
  alt="Team working on a laptop with code on screen"
  widths={[400, 800, 1200]}
  sizes="(max-width: 768px) 100vw, 1200px"
  format="avif"
  fallbackFormat="webp"
  loading="eager"
  fetchpriority="high"
/>

That call generates AVIF at three widths, a WebP fallback for browsers without AVIF support, and a srcset/sizes pair so the browser picks the right file for its viewport instead of always downloading the largest. fallbackFormat="webp" is the detail that's easy to skip and shouldn't be: it keeps the image correct in the shrinking set of browsers without AVIF, rather than falling back to the original unoptimized JPEG.

The widths array deserves a moment of thought rather than a copy-paste. Each entry is a separate Sharp encode at build time, so nine widths on two hundred images is a meaningfully slower build for resolution steps no device will ever request. Three widths spanning your real breakpoints covers almost every layout.

sizes is the half people get wrong. It describes how wide the image renders at each breakpoint, not how wide the file is, and the browser uses it to choose from srcset before layout happens. Get it wrong and it will happily download the 1200px file for a 400px slot.

Three overlapping rulers and tape measures showing different measurement scales
Photo by William Warby on Unsplash

Step 2: What do you do in React components outside the Astro pipeline?

If you're rendering images from a React island (an interactive component hydrated client-side) rather than Astro's own templating, you don't get <Image> for free, but the same principles apply manually:

interface ResponsiveImageProps {
  src: string;
  avifSrc: string;
  alt: string;
  width: number;
  height: number;
  priority?: boolean;
}

function ResponsiveImage({ src, avifSrc, alt, width, height, priority = false }: ResponsiveImageProps) {
  return (
    <picture>
      <source srcSet={avifSrc} type="image/avif" />
      <img
        src={src}
        alt={alt}
        width={width}
        height={height}
        loading={priority ? 'eager' : 'lazy'}
        fetchPriority={priority ? 'high' : 'auto'}
        decoding={priority ? 'sync' : 'async'}
      />
    </picture>
  );
}

The explicit width and height attributes here aren't decorative, they're what lets the browser compute the image's aspect ratio and reserve that exact space in the layout before a single byte of image data arrives, which is the actual fix for CLS. A <picture> with an AVIF <source> and a plain <img> fallback covers format selection without a build-time pipeline at all, at the cost of doing the resizing yourself ahead of time. Two Sharp calls in a script and the output committed to public/ is usually enough for a handful of island images.

Step 3: Which CSS preserves the aspect ratio at any size?

img {
  max-width: 100%;
  height: auto;
  aspect-ratio: attr(width) / attr(height);
}

Three declarations on the img element cover every responsive case, and the aspect-ratio line is the one doing the real work. It derives the ratio from the HTML width and height attributes directly, supported across evergreen browsers since 2023, so the reserved space matches whatever size the image actually renders at rather than its literal pixel dimensions.

Without it, a responsive image scaled by width: 100% can still shift, because the browser reserved space from the raw attribute values and the computed height disagrees once CSS gets involved.

height: auto is what makes that safe. Set an explicit CSS height alongside a width: 100% and you override the aspect ratio the attributes established, which reintroduces exactly the layout shift the attributes were there to prevent.

How Do You Measure the Before and After?

Measure with Lighthouse for the lab numbers and the web-vitals package for what real visitors see, because the two disagree more often than people expect. Here is a real article page from this site before and after the pipeline, on a throttled 4G profile:

MetricBefore (unoptimized JPEG, no dimensions)After (AVIF pipeline + explicit dimensions)
LCP3.4s1.6s
CLS0.31 (poor)0.02 (good)
Transferred image bytes480KB96KB

The CLS improvement came entirely from the width and height attributes; format conversion had zero effect on it. The LCP improvement came from both: a smaller file transfers faster, and fetchpriority="high" told the browser to prioritize that request over the fonts and scripts competing for the same bandwidth.

That table is a lab measurement on one page, which means you are taking our word for it. So we also built the same comparison as two live components you can run: the same mock article rendered twice, once with a naive full-size JPEG carrying no dimensions and loading="lazy", once with the output shape astro:assets emits. CLS, transferred bytes and LCP are read out of PerformanceObserver in your own browser, and every run cache-busts the images so a cached file cannot quietly report zero bytes.

Measurement panel reading CLS 0.103 in red, 100 kB image bytes and LCP 256 ms above a mock article page
Naive: one full-size JPEG, no width or height, loading="lazy" on the largest element. CLS 0.103, 100 kB transferred.
Measurement panel reading CLS 0.000 in green, 6 kB image bytes and LCP 252 ms above the same mock article page
The same page with explicit dimensions and an AVIF srcset: CLS 0.000, and 6 kB because the browser picked the 1200px AVIF candidate instead of the full-size original.

Two details in those readouts are worth more than the headline numbers. The optimized panel names the file the browser actually chose out of srcset, which is the only honest way to check your sizes attribute, since the browser resolves it before layout and never tells you in the markup. And the naive panel scores a harmless CLS if you put the image at the bottom of the frame with one line of text under it. CLS is impact fraction times distance fraction, so the same broken markup measures 0.02 in a cramped demo and 0.10 or worse on a real article page. If your CLS looks fine, check whether you measured somewhere the shift had nothing left to push.

Splitting the two changes across separate commits is worth the extra minute. Ship them together and you learn that the page got faster without learning which half did it, which matters the next time someone proposes dropping the attributes to simplify a component.

Why Should You Never Lazy-Load the Hero Image?

<!-- Wrong: this is almost certainly your LCP element -->
<img src="/hero.avif" alt="..." loading="lazy" />

<!-- Right: eager + high priority for the largest above-the-fold element -->
<img src="/hero.avif" alt="..." loading="eager" fetchpriority="high" />

Lazy-loading the hero image delays the request for the element most likely to be your LCP candidate, which is the opposite of what you want. loading="lazy" tells the browser to defer until the image is near the viewport, and the hero is already in the viewport on page load, so the attribute buys nothing and costs you the metric.

Reserve lazy for images below the fold, where deferring genuinely saves bandwidth on a page the user might never scroll. On the article page measured above, moving a single loading="lazy" off the hero accounted for roughly 0.6s of the LCP improvement on its own, before any format conversion.

The failure is easy to introduce and hard to spot, because a blanket loading="lazy" on every img looks like a tidy optimization in review and passes every functional test.

How Do You Audit an Existing Site for This Gap?

Audit an inherited codebase by running Lighthouse against three or four representative pages and reading two audits separately: "Serve images in next-gen formats" for file size, and "Avoid large layout shifts" for CLS. A pass on one and a fail on the other is the exact signal that a previous optimization pass converted formats without touching dimensions, or the reverse, someone added width/height attributes to stop a CLS warning without ever revisiting the source format. Both audits need to pass on the same page before you can call the pipeline complete; a green score on one implies nothing about the other.

Lighthouse only tells you about the pages you point it at, so pair it with a repository-wide check. Grep for <img without a neighbouring width=, and for loading="lazy" appearing anywhere in a hero or banner component. On a codebase of any age those two searches find more real problems in five minutes than a week of per-page auditing.

Printed task checklist on a clipboard resting next to a laptop
Photo by Markus Winkler on Unsplash

The measurement above came from one article page, but the pattern generalises: in every audit I've run on an inherited Astro or React site, format conversion had been done at least partially and dimensions had been skipped entirely.

Conclusion

Format optimization and layout stability are separate fixes with separate causes, and a pipeline that only handles one leaves real Core Web Vitals points on the table. astro:assets (or a manual <picture> pattern for React islands) handles AVIF generation and responsive sizing; explicit width/height handles CLS. Both are needed, and neither substitutes for the other.

Frequently Asked Questions

Is AVIF always better than WebP?
AVIF typically compresses 20 to 30 percent smaller than WebP at equivalent visual quality, especially for photographic content. It's not universally better: AVIF encoding is slower at build time, and very old browsers (a shrinking minority by 2026) don't support it at all. The pattern that covers both cases is generating both formats and letting the browser pick via a <picture> element with AVIF listed first, falling back to WebP.
Does lazy loading images hurt or help Core Web Vitals?
It helps LCP and total page weight when applied to below-the-fold images, but it actively hurts LCP if applied to the hero image itself. A lazy-loaded LCP image delays the browser's request for it until the image enters the viewport calculation, exactly the opposite of what you want for the largest above-the-fold element. Use loading="eager" and fetchpriority="high" on the LCP image, loading="lazy" on everything below the fold.
How does setting width and height prevent layout shift if the image is responsive?
The width and height attributes establish the image's aspect ratio, not its literal pixel size. The browser reserves space based on that ratio before the image loads, then CSS (like width: 100%; height: auto;) scales the rendered box to fit its container while preserving the same ratio. The reserved space and the final rendered size end up matching, which is what prevents the layout jump once the image data arrives.