Skip to content

Image Optimization for Astro and React: AVIF and CLS

Building an Image Pipeline That Fixes CLS, Not Just File Size

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

· · 5 min read

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.

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.

TL;DR: 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.

Step 1: Format Generation With astro:assets

Astro's built-in astro:assets module handles AVIF/WebP generation and responsive sizing without a separate build tool, using Sharp under the hood:

---
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"
/>

This one component 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 one for its viewport instead of always downloading the largest. fallbackFormat="webp" is the detail that's easy to skip and shouldn't be, it's what keeps the image working correctly in the shrinking set of browsers without AVIF support, rather than falling back to the original unoptimized JPEG.

Step 2: For React Components Outside Astro's Image 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 needing a build-time image pipeline at all, at the cost of doing the resizing yourself ahead of time.

Step 3: CSS That Preserves the Aspect Ratio at Any Size

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

aspect-ratio here derives the ratio from the HTML width/height attributes directly (supported since 2023 in evergreen browsers), so the reserved space matches whatever size the image actually renders at, not just its literal pixel dimensions. Without this, a responsive image that scales down via width: 100% can still shift if its CSS-computed height doesn't match the space reserved from the raw attribute values.

Measuring the Before and After

I ran this pipeline against a real article page and measured with Lighthouse and the web-vitals package 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/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 other resources competing for bandwidth.

Common Mistake: Lazy-Loading 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" />

loading="lazy" on an image tells the browser to defer the request until it's near the viewport, which is exactly wrong for an image that's already in the viewport on page load, it delays the request for the element most likely to be your LCP candidate. Reserve lazy for images below the fold, where deferring the request genuinely saves bandwidth on a page the user might not scroll to.

Auditing an Existing Site for This Gap

If you inherited a codebase and want to know how much of this is already missing, run Lighthouse against a handful of representative pages and check two things separately: the "Serve images in next-gen formats" audit (file size) and the "Avoid large layout shifts" audit (CLS). Seeing 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 doesn't imply anything about the other.

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.