Your Astro site ships zero JavaScript. Your Lighthouse performance score is green. And your LCP is still 4.2 seconds.
The problem isn't Astro. It's your images.
Astro's zero-JS architecture eliminates the most common cause of slow sites — render-blocking JavaScript. But LCP measures when the largest visible element finishes rendering, and that element is almost always an image. A 1.8MB uncompressed JPEG doesn't care how little JavaScript you ship. It's still going to take 3 seconds to download on a 4G connection.
This guide walks through the five image mistakes that most commonly hurt LCP on Astro sites, including a default behavior that works against you out of the box. Every fix includes before-and-after measurements so you can prioritize based on impact.
Here's what a typical unoptimized Astro site looks like:
| Metric | Value |
|---|---|
| LCP | 4.2s |
| CLS | 0.14 |
| INP | 45ms |
| Hero image size | 1.8MB |
| PageSpeed mobile | 52 |
Notice the INP is already excellent — that's Astro's zero-JS advantage. But the LCP and CLS scores are dragging the overall score down, and both are caused entirely by images.
Fix 1: The lazy loading trap (saved 1.4s LCP)
This is the most impactful fix, and it catches almost everyone.
Astro's <Image /> component defaults to loading="lazy" and decoding="async". For below-the-fold images, this is exactly right. For your hero image — the one that's almost always your LCP element — it's a performance killer.
The problem
---
import { Image } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
---
<!-- This looks correct but hurts LCP -->
<Image src={heroImage} alt="Product hero shot" />
With loading="lazy", the browser won't start fetching the image until it enters the viewport. Since the hero image is above the fold, the browser has to:
- Download the HTML
- Parse the DOM
- Run layout to determine the image is in the viewport
- Then start the image request
That layout step adds 200-400ms before the image download even begins. On a mid-tier mobile device, it's worse.
The fix
---
import { Image } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
---
<!-- Explicitly override defaults for LCP images -->
<Image
src={heroImage}
alt="Product hero shot"
loading="eager"
fetchpriority="high"
/>
Two attributes change everything:
loading="eager"tells the browser to start fetching immediately, not wait for layoutfetchpriority="high"tells the browser this image is more important than other resources, so it should be prioritized in the network queue
Unlike Next.js, Astro doesn't have a priority prop that handles both of these for you. You need to set them manually.
Add a preload hint for maximum speed
For the absolute fastest LCP, add a preload link in your layout's <head>:
---
// src/layouts/Layout.astro
---
<html>
<head>
<link
rel="preload"
href="/optimized-hero.webp"
as="image"
type="image/webp"
fetchpriority="high"
/>
</head>
<body>
<slot />
</body>
</html>
Preloading starts the image download before the browser even encounters the <img> tag in the DOM. Combined with loading="eager", this eliminates all unnecessary delay.
Results
| Metric | Before | After | Change |
|---|---|---|---|
| LCP | 4.2s | 2.8s | -1.4s |
| Image load start | 1.2s | 0.3s | -900ms |
1.4 seconds from two HTML attributes. This single fix is often the difference between a "poor" and "needs improvement" LCP score.
Fix 2: Use Picture for format negotiation (saved 340KB)
Astro's <Image /> component outputs WebP by default. That's good — WebP is 25-30% smaller than JPEG. But AVIF is 20-30% smaller than WebP, and 93% of browsers now support it.
The problem
---
import { Image } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
---
<!-- Only serves WebP — misses AVIF savings -->
<Image src={heroImage} alt="Product hero shot" loading="eager" />
This outputs a single WebP file. Browsers that support AVIF (Chrome, Firefox, Safari 16+) still get the larger WebP.
The fix
---
import { Picture } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
---
<Picture
src={heroImage}
formats={['avif', 'webp']}
alt="Product hero shot"
loading="eager"
fetchpriority="high"
/>
This generates a <picture> element with multiple <source> tags. The browser picks AVIF if it can, falls back to WebP, and uses the original format as a last resort.
Real file sizes
For a 1600x900 hero image at default quality:
| Format | File size | vs Original JPEG |
|---|---|---|
| JPEG (original) | 485KB | baseline |
| WebP | 192KB | -60% |
| AVIF | 145KB | -70% |
The AVIF version is 340KB smaller than the original and 47KB smaller than WebP. On a 4G connection, that's roughly 200ms faster to download.
Results
| Metric | Before | After | Change |
|---|---|---|---|
| Hero image size | 485KB (JPEG) | 145KB (AVIF) | -70% |
| LCP | 2.8s | 2.4s | -400ms |
Fix 3: Responsive images for mobile (saved 600ms LCP on mobile)
A 1600px-wide hero image is perfect for desktop. On a 375px-wide phone screen, you're downloading 4x more pixels than the screen can display. That's wasted bytes and wasted time.
The problem
---
import { Image } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
---
<!-- Same large image sent to all devices -->
<Image
src={heroImage}
alt="Product hero shot"
loading="eager"
fetchpriority="high"
/>
Without responsive sizing, every device downloads the full-resolution image.
The fix (Astro 5.10+)
Astro's responsive image layout generates srcset and sizes automatically:
---
import { Image } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
---
<Image
src={heroImage}
alt="Product hero shot"
loading="eager"
fetchpriority="high"
layout="full-width"
/>
The layout="full-width" prop tells Astro to generate multiple sizes and let the browser pick the right one based on the viewport. No manual srcset needed.
Enable the global responsive styles in your config:
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
image: {
responsiveStyles: true,
},
});
For more control over breakpoints, use the widths prop with <Picture>:
---
import { Picture } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
---
<Picture
src={heroImage}
formats={['avif', 'webp']}
widths={[400, 800, 1200, 1600]}
sizes="(max-width: 600px) 400px, (max-width: 1024px) 800px, (max-width: 1400px) 1200px, 1600px"
alt="Product hero shot"
loading="eager"
fetchpriority="high"
/>
Mobile impact
| Device | Image downloaded | Before | After |
|---|---|---|---|
| Desktop (1440px) | 1600w AVIF | 145KB | 145KB |
| Tablet (768px) | 800w AVIF | 145KB | 62KB |
| Phone (375px) | 400w AVIF | 145KB | 28KB |
On mobile, the image is 80% smaller. That translates directly to LCP improvement.
Results
| Metric | Before (mobile) | After (mobile) | Change |
|---|---|---|---|
| Hero image (mobile) | 145KB | 28KB | -80% |
| LCP (mobile) | 2.4s | 1.8s | -600ms |
Fix 4: Astro 6.1 codec-specific Sharp defaults (saved 18% site-wide)
Before Astro 6.1, tuning image encoding meant setting options on every single <Image /> component. If you wanted MozJPEG compression for all JPEG fallbacks or higher AVIF effort for better compression, you'd repeat yourself across dozens of files.
Astro 6.1 introduced global codec-specific defaults. Configure once, apply everywhere.
The problem
---
// Without global config, you'd repeat this everywhere
import { Image } from 'astro:assets';
import photo from '../assets/photo.jpg';
---
<!-- No way to set MozJPEG or AVIF effort globally before 6.1 -->
<Image src={photo} alt="Team photo" quality={75} />
The fix
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
image: {
service: {
config: {
jpeg: {
mozjpeg: true, // Better compression at same quality
},
webp: {
effort: 6, // Higher effort = smaller files, slower builds
alphaQuality: 80, // Quality for transparent WebP
},
avif: {
effort: 4, // Balance between size and build speed
chromaSubsampling: '4:2:0', // Standard subsampling for photos
},
png: {
compressionLevel: 9, // Maximum PNG compression
},
},
},
},
});
Each codec option maps directly to Sharp's encoding API. The most impactful settings:
jpeg.mozjpeg: true— MozJPEG produces 5-10% smaller JPEGs than the default encoder at the same visual quality. It's slower to encode but since this runs at build time, you don't care.avif.effort: 4— AVIF effort ranges from 0 (fastest, largest) to 9 (slowest, smallest). Default is 4. If your build times are tolerable, try 6 for an extra 5-8% compression.webp.effort: 6— Similar tradeoff. Higher effort squeezes more bytes out.
Per-image quality props still take precedence over global defaults, so you can override for specific images that need higher or lower quality.
Results
Across a 40-page site with 120 images:
| Format | Before (avg) | After (avg) | Saved |
|---|---|---|---|
| JPEG fallbacks | 89KB | 76KB | -15% |
| WebP | 64KB | 52KB | -19% |
| AVIF | 48KB | 39KB | -19% |
| Total image weight | 7.2MB | 5.9MB | -18% |
Build time increased from 28s to 34s — a worthwhile trade for permanently smaller images.
Fix 5: Remote images from your CMS (saved 1.1s LCP)
If your content comes from a headless CMS — Contentful, Sanity, Strapi, or similar — your hero images are probably remote URLs. Astro won't optimize them unless you explicitly allow the domain.
The problem
---
import { Image } from 'astro:assets';
---
<!-- Remote image — Astro renders this WITHOUT optimization -->
<Image
src="https://cdn.contentful.com/spaces/abc123/hero.jpg"
alt="Blog hero"
width={1200}
height={630}
loading="eager"
/>
Without the domain in your config, Astro still renders the <img> tag (preserving width/height for CLS prevention), but the image bypasses Sharp entirely. No format conversion, no compression, no resizing. You get the raw file from the CMS CDN.
The fix
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
image: {
domains: ['cdn.contentful.com', 'images.ctfassets.net'],
// Or use patterns for more flexibility
remotePatterns: [
{
protocol: 'https',
hostname: '**.sanity.io',
},
],
},
});
Once the domain is authorized, Astro processes remote images through Sharp — converting to WebP/AVIF, compressing, and resizing just like local images.
For SSR sites: caching matters
If you're running Astro in SSR mode (not static), remote images are processed on each request by default. Astro caches processed images in ./node_modules/.astro/ and respects the remote server's Cache-Control headers. Since Astro 5.1, it also uses Last-Modified and ETag headers to avoid re-downloading unchanged images.
Make sure your CMS CDN sends proper cache headers. Most do by default, but it's worth verifying:
curl -sI https://cdn.contentful.com/spaces/abc123/hero.jpg | grep -i cache-control
# Should show something like: cache-control: max-age=31536000
Results
| Metric | Before (unoptimized remote) | After (optimized) | Change |
|---|---|---|---|
| Hero image size | 1.4MB (JPEG) | 145KB (AVIF) | -90% |
| LCP | 3.6s | 2.5s | -1.1s |
The full picture
Five fixes, each targeting a different part of the image pipeline:
| Fix | Technique | LCP Impact |
|---|---|---|
| 1 | loading="eager" + fetchpriority="high" | -1.4s |
| 2 | <Picture> with AVIF + WebP | -400ms |
| 3 | Responsive images with layout | -600ms (mobile) |
| 4 | Astro 6.1 codec-specific Sharp defaults | -18% image weight |
| 5 | Authorize remote CMS domains | -1.1s |
Applied together to our test site:
| Metric | Before | After | Change |
|---|---|---|---|
| LCP (mobile) | 4.2s | 1.4s | -67% |
| LCP (desktop) | 2.8s | 0.9s | -68% |
| CLS | 0.14 | 0.01 | -93% |
| INP | 45ms | 42ms | No meaningful change |
| Total image weight | 7.2MB | 5.9MB | -18% |
| PageSpeed mobile | 52 | 96 | +44 points |
The CLS improvement comes from <Image /> and <Picture /> automatically inferring dimensions. The INP was already good — that's Astro's zero-JS advantage.
Image optimization checklist for Astro
Before deploying, verify each of these:
- LCP image uses
loading="eager"andfetchpriority="high" - LCP image uses
<Picture>withformats={['avif', 'webp']} - All other images use
<Image />(not raw<img>) - Images are in
src/notpublic/(so Sharp can process them) - Responsive
layoutor manualwidths/sizesset for hero images - Remote CMS domains added to
image.domainsorimage.remotePatterns - Astro 6.1+ codec defaults configured in
astro.config.mjs - No images above the fold using
loading="lazy"(the default)
Frequently Asked Questions
Why is my Astro site's LCP slow when Astro is supposed to be fast?
Astro ships zero JavaScript by default, but LCP is usually about images, not JavaScript. If your hero image is unoptimized, uses the default loading="lazy", or loads from an unauthorized remote domain, your LCP will suffer regardless of how little JavaScript you ship. Use Astro's <Image /> component with loading="eager" and fetchpriority="high" on your LCP image.
Should I use the Image component or the Picture component in Astro?
Use <Picture> for your LCP element and any image where you want the browser to choose between AVIF and WebP. <Picture> generates multiple <source> elements so the browser picks the smallest format it supports. Use <Image> for simpler cases where WebP alone is sufficient.
What image format is best for Astro — WebP or AVIF?
AVIF produces 20-30% smaller files than WebP at comparable quality, but takes longer to encode at build time. Use the <Picture> component with formats={['avif', 'webp']} to serve AVIF to browsers that support it and WebP as a fallback. Astro 6.1's codec-specific config lets you tune encoding effort globally to balance file size and build speed.
How do I optimize remote images from a CMS in Astro?
Add your CMS domain to the image.domains array or use image.remotePatterns in astro.config.mjs. Without this, Astro renders remote images without optimization — they bypass Sharp entirely. This is a common gotcha for headless CMS setups where hero images come from external CDNs.
Does Astro's Image component prevent CLS?
Yes. Astro's <Image /> component automatically infers width and height from local images, which reserves space in the layout and prevents Cumulative Layout Shift. For remote images, you must provide width and height manually. Using the responsive layout prop (constrained, full-width, or fixed) also handles sizing automatically.
What's next
If you've optimized your images and LCP is still slow, the problem might be elsewhere — render-blocking CSS, slow server response, or third-party scripts blocking the main thread. For a broader look at Astro performance beyond images, see our guide on Astro's zero-JS architecture and islands.
Need help identifying exactly what's slowing your Astro site down? Run it through PageSpeedFix for a detailed Core Web Vitals analysis with framework-specific fixes.
