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:assetsand Sharp) and layout stability (explicitwidth/heightso the browser reserves space before the image loads). Usefetchpriority="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:
| Metric | Before (unoptimized JPEG, no dimensions) | After (AVIF pipeline + explicit dimensions) |
|---|---|---|
| LCP | 3.4s | 1.6s |
| CLS | 0.31 (poor) | 0.02 (good) |
| Transferred image bytes | 480KB | 96KB |
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.