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.

Build-time image pipeline in Astro A source JPEG flows through the Sharp service, which emits AVIF and WebP variants at three widths, producing a responsive srcset served to the browser. hero.jpg 1.4 MB source Sharp service resize + encode at build time AVIF · 400/800/1200 WebP · 400/800/1200 srcset browser picks ~120-280 KB One source in, responsive modern formats out
A single 1.4 MB source becomes AVIF and WebP variants at three widths; the browser downloads the smallest that fits — often under 280 KB.

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 caseReach forWhy
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 yourselfplain <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.

How the browser picks a srcset candidate Two viewports read the same srcset of 400w, 800w and 1200w and the same sizes rule. A 1200px desktop slot picks the 1200w file at 1x and stays capped at 1200w at 2x, while a 400px mobile slot picks 400w at 1x and 800w at 2x. Same srcset and sizes, four different downloads srcset 400w · 800w · 1200w · sizes: 100vw if ≤800px, else 1200px Desktop viewport — 1200 px sizes → 1200 px slot Mobile viewport — 400 px sizes → 100vw = 400 px slot 1× DPR needs 1200 px → browser picks 1200w 2× DPR needs 2400 px → 1200w (largest available) 1× DPR needs 400 px → browser picks 400w 2× DPR needs 800 px → browser picks 800w
Device pixel ratio, not just viewport width, decides the candidate: the same 400px mobile slot pulls the 400w file at 1x but the 800w file on a 2x screen, while the desktop slot caps out at the largest 1200w candidate.

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)BytesLCP (throttled mobile)
Original JPEG q901.4 MB3.1s
WebP q80240 KB1.9s
AVIF q80180 KB1.7s
AVIF q65132 KB1.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: ![Alt](./diagram.png) 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. Use loading="eager" and fetchpriority="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 sizes that 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. Match sizes to the real rendered width.
  • Everything in public/: files there skip the pipeline. If a big JPEG is shipping unoptimized, check that it is imported from src/ rather than served from public/.
  • Build timeouts on huge media dirs: processing hundreds of large images can exhaust a runner. Cache node_modules/.astro, set SHARP_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.
Which primitive to reach for Three panels: Image for a single optimised format with known dimensions, Picture for multiple formats or art direction, and a plain img from public for assets you manage yourself. Each lists what it costs and when it is wrong. Which primitive to reach for Image one format, one source dimensions inferred at build the default for body images wrong when you need AVIF + WebP Picture multiple formats with fallback per-breakpoint art direction more encodes per source wrong for a simple inline image Plain img from public bypasses the pipeline entirely pre-optimised SVG or vendor asset no dimensions inferred wrong for anything photographic A build check that fails on large files in public/ catches the mistake before it ships.
Most performance regressions in an Astro image pipeline are a photograph that ended up in the third column.

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 truthful sizes.
  • 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/.astro build, capping SHARP_CONCURRENCY on 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.