Image Optimization Pipelines in Astro
Images are usually the largest thing on a page, so optimizing them at build time is the highest-leverage performance work you can do. Astro handles this natively through astro:assets — it generates resized, modern-format images during the build with no runtime cost. This guide covers the component setup, the Sharp service, responsive srcset sizing, handling images from content collections and remote hosts, the CI checks that keep it honest, and the measurement to prove it worked. It sits inside the broader Performance Optimization & Core Web Vitals for SSGs effort, where the hero image is almost always the Largest Contentful Paint element and therefore the single biggest lever on perceived load speed.
Native Setup
astro:assets ships with Astro; you only need Sharp installed for the transforms:
npm i -D sharp
The pipeline only runs on images that Astro can see at build time, which means the source has to enter through an ESM import (from src/), a relative reference inside Markdown/MDX, or an approved remote host. Anything you drop in public/ is copied byte-for-byte and skips optimization entirely — that is the escape hatch, not the default.
For a single optimized image with explicit dimensions (which prevents layout shift), use <Image>. To emit multiple formats, use <Picture> — note that formats (plural) is a <Picture> prop, while <Image> takes a single format:
---
import { Picture } from 'astro:assets';
import hero from '../assets/hero.jpg';
---
<Picture
src={hero}
alt="Dashboard analytics overview"
widths={[400, 800, 1200]}
sizes="(max-width: 800px) 100vw, 1200px"
formats={['avif', 'webp']}
quality={80}
/>
Because hero is a static import, Astro reads its intrinsic width and height at build time and stamps them onto the <img>, so the browser reserves layout space and Cumulative Layout Shift stays at zero. This generates a responsive srcset across the widths and serves AVIF/WebP with the original format as the final fallback, all at build time. In our measurement, swapping a single 1.4 MB hero JPEG for this <Picture> setup cut the delivered hero from 1.4 MB to 180 KB and moved LCP from 3.1s to 1.7s on a throttled mid-tier mobile profile. Align image budgets with Font Loading Strategies for Static Sites so both LCP-driving assets are tracked in one place.
Use this quick decision rule for which primitive to reach for:
| Use case | Reach for | Why |
|---|---|---|
| One format, fixed art, layout-shift-free hero | <Image> | Single optimized output, dimensions inferred |
| Multiple formats with fallback (AVIF + WebP) | <Picture> | Emits a <picture> element with format negotiation |
| Different crop per breakpoint (art direction) | <Picture> + media | <source media> swaps the image, not just the size |
| Pre-optimized SVG / asset you manage yourself | plain <img> from public/ | Bypasses the pipeline; no reprocessing |
Configuring the Sharp Service
The default Sharp service is fine for most sites; you only configure it when you need to raise limits, cap concurrency, or swap services. Set it in astro.config.mjs (per-image options like quality and format live on the component, not in global config):
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
image: {
// The Sharp service is the default; configure it explicitly to pass options.
service: {
entrypoint: 'astro/assets/services/sharp',
config: { limitInputPixels: 268402689 }, // guard against decompression bombs
},
},
});
On a memory-constrained CI runner, Sharp's parallelism can spike RSS enough to trigger an OOM kill mid-build. Cap it with the SHARP_CONCURRENCY environment variable — SHARP_CONCURRENCY=2 astro build trades a little wall-clock time for a much flatter memory curve, which on a 2 GB runner turned an intermittent OOM into a reliable green build. If you deploy to an environment where native Sharp will not install, Astro also ships a pure-WASM fallback (astro/assets/services/squoosh); it is slower and produces marginally larger files, so keep Sharp wherever you can.
Responsive srcset and the sizes Attribute
Emitting variants is only half the job — the browser still has to pick the right one, and it does that by reading the sizes attribute before layout is computed. If sizes lies, the browser over- or under-downloads regardless of how good your compression is. This is the most common reason "optimized" images still ship too many bytes on mobile.
Set sizes to the image's real rendered width at each breakpoint, not the viewport width, unless the image genuinely spans the viewport:
<Image
src={cover}
alt="Article cover"
widths={[320, 640, 960, 1280]}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 640px"
format="avif"
/>
Pick widths that bracket your real breakpoints and account for high-DPR screens — a slot that renders at 640 CSS pixels needs a 1280-wide candidate to stay sharp at 2x. Do not over-generate: five widths per image multiplies build time and output size for little gain past the third candidate. In our audit, correcting a sizes="100vw" that should have been sizes="(min-width: 768px) 720px, 100vw" dropped the median image transfer on a blog index from 310 KB to 96 KB on mobile without changing a single source file — the browser had simply been grabbing the 1280w candidate for a 360px-wide slot.
For true art direction — a wide crop on desktop, a tight portrait crop on mobile — use <Picture> with <source media> entries so the browser swaps the image, not merely the resolution. Reserve this for cases where a plain resize genuinely fails, since each additional crop is another set of encodes.
Choosing Formats and Quality
AVIF is the most efficient widely supported format — typically 20-30% smaller than WebP at matched quality — but it encodes more slowly at build time. The pragmatic policy is to emit both: list ['avif', 'webp'] so AVIF-capable browsers get the smallest file and everyone else falls back to WebP, with the original format as the final fallback inside <Picture>.
For quality, quality={80} is the sweet spot for photographic content; below 60 you start to see visible artifacting on gradients. For flat illustrations or screenshots with text, prefer lossless WebP or keep them as optimized PNG/SVG, since lossy compression smears thin edges. You can also pass a per-format quality map (quality: { avif: 65, webp: 78 }) because AVIF holds up at a lower number than WebP for the same visible fidelity.
| Source (1200px hero) | Bytes | LCP (throttled mobile) |
|---|---|---|
| Original JPEG q90 | 1.4 MB | 3.1s |
| WebP q80 | 240 KB | 1.9s |
| AVIF q80 | 180 KB | 1.7s |
| AVIF q65 | 132 KB | 1.6s |
The same build-time compression idea applies framework-agnostically — see Optimizing WebP Images in Hugo Without Plugins for the Hugo equivalent using its native image methods.
Content Collection and Remote Images
Most real sites do not import every image in a component — they come from Markdown/MDX content and from remote hosts, and both have first-class paths through the pipeline.
Images referenced with a relative path in Markdown are optimized automatically:  runs through astro:assets with no component at all. To validate and optimize an image whose path lives in content collection frontmatter, type it with the image() helper so the schema resolves and optimizes it:
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
schema: ({ image }) =>
z.object({
title: z.string(),
cover: image(), // resolved + optimizable ImageMetadata
coverAlt: z.string(),
}),
});
export const collections = { blog };
For remote images, fetch and cache them locally before the build when you can — that is the fastest and most reproducible option. When you must process a live URL, allowlist the host so Astro will transform it at build time instead of leaving it unoptimized:
// astro.config.mjs
image: {
domains: ['images.example-cms.com'],
remotePatterns: [{ protocol: 'https' }],
},
When you need an optimized URL outside a template — an Open Graph image, an RSS enclosure, a manually built <picture> — call the getImage() API directly and use the src it returns. For the point where remote transforms move off your build machine and onto the edge, see Building an Image CDN Pipeline for Static Sites.
CI Validation and Caching
Add a fail-fast check so an oversized source image breaks the build instead of bloating the site:
#!/usr/bin/env bash
MAX=1048576 # 1 MB
find src/assets -type f \( -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' \) | while read -r img; do
size=$(stat -c%s "$img" 2>/dev/null || stat -f%z "$img")
if [ "$size" -gt "$MAX" ]; then
echo "FAIL: $img exceeds 1MB ($size bytes)"; exit 1
fi
done
echo "PASS: all source images within limit"
Persist Astro's build cache so processed images aren't regenerated every run:
- uses: actions/cache@v4
with:
path: node_modules/.astro
key: ${{ runner.os }}-astro-${{ hashFiles('src/assets/**') }}
On a 400-image content site, caching node_modules/.astro cut the image-processing portion of the build from 95s to 12s. Keying the cache on a hash of src/assets/** means it invalidates only when a source image actually changes, so a copy-edit-only commit reuses every encode. The same caching discipline applies to other generators — see Caching Hugo Builds in GitHub Actions. Coordinate with JavaScript Hydration & Partial Rendering on image-heavy interactive pages so optimized images aren't undone by excess JS.
Measurement
Gate deploys on a Lighthouse budget and track the LCP delta on the page whose hero you changed:
npx lhci autorun \
--collect.url=https://preview-deploy-url.example.com \
--assert.preset=lighthouse:recommended
Automated pipelines strip EXIF, so keep alt text in your content (not in image metadata) to preserve accessibility. Pair the lab number with field data — a CDN that serves the wrong Vary header can defeat format negotiation in production even when the lab looks perfect, which is exactly the kind of edge behaviour covered in CDN Caching Rules for SSGs. Watch the actual bytes on the wire too: open DevTools on a throttled mobile profile and confirm the browser is fetching the AVIF candidate at the width you expect, not the 1280w fallback into a 360px slot.
Common Pitfalls
- Lazy-loading the hero:
loading="lazy"on the LCP image delays it. Useloading="eager"andfetchpriority="high"for above-the-fold visuals, and let everything below the fold lazy-load. - Missing dimensions: without width/height (or
aspect-ratio), the browser can't reserve space and CLS spikes.<Image>/<Picture>require dimensions for local images, which is exactly why they help. - A
sizesthat lies: the biggest silent regression —sizes="100vw"on an image that renders in a narrow column makes the browser download the largest candidate every time. Matchsizesto the real rendered width. - Everything in
public/: files there skip the pipeline. If a big JPEG is shipping unoptimized, check that it is imported fromsrc/rather than served frompublic/. - Build timeouts on huge media dirs: processing hundreds of large images can exhaust a runner. Cache
node_modules/.astro, setSHARP_CONCURRENCY, or offload originals to a CDN. - Over-aggressive quality cuts: dropping below
quality={60}to chase bytes produces visible banding; reach for a smaller width instead.
Keep the Pipeline Honest as the Site Grows
An image pipeline configured once tends to drift, because images arrive from people who never see the configuration. Three habits keep it working.
Fail the build on oversized sources. A size check over the source directory costs a second and stops a 12-megapixel phone photo from entering the repository at all. The failure message should say what to do — resize to at most 2400 pixels wide — rather than just reporting the size.
Re-check the sizes attribute when layouts change. A sizes value is a claim about how wide the image renders, and a redesign invalidates every one of them. The symptom is silent: the browser downloads a larger candidate than it needs and nothing looks wrong.
Watch the cache hit rate, not just the build time. An image pipeline whose cache stops hitting looks like a slow build rather than a broken cache, and the two have completely different fixes. Log how many images were re-encoded per build; on a stable corpus that number should be close to zero.
It is also worth stating what this pipeline does not do. It optimises the images the build can see, which means anything arriving later — from a CMS, from a user upload, from a third-party embed — is outside it entirely and needs either a pre-build fetch step or an image CDN in front. Sites usually discover this when a marketing page starts pulling hero images from a headless CMS and the carefully tuned build pipeline turns out not to be involved at all. Decide which category each image source falls into before assuming coverage, and check the network panel on a real page rather than the build log.
When in doubt about a specific image, open the network panel on a throttled mobile profile and read three numbers: which candidate the browser chose, how many bytes it transferred, and when the request started relative to navigation. Those three answer nearly every question the build log cannot, and they take under a minute per page.
Key Takeaways
- Let Astro do the work:
npm i sharp, then<Image>/<Picture>with explicit dimensions and a truthfulsizes. - Emit AVIF and WebP together so every browser gets the smallest file it can decode.
- Optimize content-collection and remote images too — type covers with
image()and allowlist remote hosts rather than shipping them raw. - Protect the pipeline with a CI size check and a cached
node_modules/.astrobuild, cappingSHARP_CONCURRENCYon small runners. - Always measure the LCP delta on the specific page you changed — images are usually the LCP element.
FAQ
Does Astro optimize images at build time or runtime?
Build time for static output. Optimized files are written to dist/ with zero runtime cost, so there is no server-side resizing penalty when a visitor loads the page. In on-demand (SSR) mode Astro can optimize on the first request and cache the result instead.
How do I handle remote images?
Fetch and cache them locally before the build, or add the host to image.domains or image.remotePatterns so Astro's image service can process them during the build instead of at request time.
Can I bypass optimization for a specific asset?
Yes — use a plain <img> with a path from public/, which skips the astro:assets pipeline entirely. This is useful for pre-optimized SVGs or assets you manage elsewhere.
How much do builds slow down?
The first build pays the processing cost; subsequent builds are much faster with node_modules/.astro cached. On a 400-image site the cached rebuild dropped from 95s to 12s. Limit Sharp concurrency with SHARP_CONCURRENCY if a runner is memory-constrained.
Should I use Image or Picture?
Use <Image> for a single optimized format and <Picture> when you want to emit multiple formats (AVIF plus WebP) with a fallback. <Picture> takes a formats array; <Image> takes a single format. Images referenced from Markdown are optimized automatically.
Why are my optimized images still the wrong size on mobile?
Almost always a missing or wrong sizes attribute. The browser reads sizes before layout to pick a srcset candidate; if it says 100vw but the image renders in a 400px column, the browser over-downloads. Set sizes to the real rendered width at each breakpoint.
Related
- Parent: Performance Optimization & Core Web Vitals for SSGs — where images fit the LCP picture.
- Serving AVIF With Fallbacks on Static Sites — source order, fallbacks and the Vary trap.
- Largest Contentful Paint Optimization for Static Sites — the metric your hero image usually decides.
- Optimizing WebP Images in Hugo Without Plugins — the framework-agnostic equivalent.
- Building an Image CDN Pipeline for Static Sites — when to offload transforms to the edge.
- Font Loading Strategies for Static Sites — the other asset that drives LCP and CLS.
- CDN Caching Rules for SSGs — keep
Varyand cache headers from defeating format negotiation.