Optimizing Images with Astro (part 1)

From hand-resizing images to Astro and the Netlify Image CDN: what the framework automates for free (AVIF/WebP, srcset, CLS), and how a flawed first benchmark — measuring strategy and deploy pipeline at once — pushed me to a cleaner method where auto wins the baseline.

18 min read
Share
Optimizing Images with Astro (part 1)

Images are often the heaviest thing your browser has to download. That makes them largely responsible for poor performance scores. Two metrics come up most often for images: LCP (Largest Contentful Paint) and CLS (Cumulative Layout Shift). We’ll see they’re sometimes not enough to decide the best optimization strategy.

The optimg project serves as concrete examples throughout this article.

The strategies to test

StrategyDescription
naiveOne version per image, no srcset, no reserved dimensions (width, height).
manualThree widths re-encoded by a Sharp/ImageMagick script (quality 78), plus a tiny placeholder image (Low Quality Image Placeholder, LQIP), served statically from /public/. Zero framework, zero lock-in.
autoAstro’s <Picture> component with no tuning: modern AVIF/WebP formats, srcset, and sizes generated automatically.
pixel-perfectThe same <Picture> component, with a sizes attribute computed from the real layout to serve exactly the size of the image’s reserved slot.
lqipauto with a low-quality blurred placeholder filling the reserved slot before the full image loads.
croppedauto with a per-image crop, reducing the number of pixels transferred for the same displayed width.
finalThe complete optimization stack: pixel-perfect + LQIP + fade animation exempted on the LCP image + above-the-fold images split from the rest.

First approach: manual — not so naive

You can do without a framework entirely to automate creating and displaying images, using scripts and CSS. It’s a perfectly valid approach. The idea is the same: show the visible images as fast as possible while adapting to the screen’s dimensions.

The basics — a fluid image in CSS:

img { display: block; max-width: 100%; height: auto; }

A single version of the image often isn’t enough. You need to serve other sizes of the same image to adapt (“responsive”) to the viewport width, the device pixel ratio (DPR), and the page’s various display layouts (thumbnails, hero, grid, columns, etc.). For example, if an image has a 1024px slot available, a screen with a DPR of 2 will want double that — a 2048px image.

In HTML, the srcset and sizes attributes let the browser pick the best-fitting, lightest image to load.

<img
  src="640.jpg"
  srcset="640.jpg 640w, 768.jpg 768w, 1024.jpg 1024w,
          1280.jpg 1280w, 1536.jpg 1536w, 2048.jpg 2048w"
  sizes="(max-width: 1023px) 100vw, (max-width: 1536px) 50vw, 768px"
  alt="">

The sizes values have to match your real CSS layout, which the framework can’t infer on its own. In this example, we want the image full-width (100vw) below 1024px, and only half (50vw) on a larger screen — which in our layout maps to a two-column display, capped at 768px. Get sizes wrong and the browser downloads the wrong file, inflating the page load for nothing.

A basic script to generate the various sizes and a blurred version with ImageMagick:

for SIZE in "${SIZES[@]}"; do
  convert "$FILENAME" -resize $SIZE "out/${NAME}_$SIZE.$EXT"
  if [ "$SIZE" == "640x" ]; then
    convert "out/${NAME}_$SIZE.$EXT" -blur 0x8 "out/${NAME}_$SIZE-blur.$EXT"
  fi
done

AI assistants are very handy for generating this kind of script. This first strategy isn’t so naive: several image sizes are generated at creation time, served statically, with no dependency on a third-party service.

Second approach: Astro

Astro’s <Picture> component takes over most of the previous work. It generates a <picture> element with one <source> per modern format and an <img> fallback. You just list the formats from most modern to oldest — the order is the display priority.

---
import { Picture } from "astro:assets";
import hero from "./hero.png"; // 1600x900
---
<Picture
  src={hero}
  formats={["avif", "webp"]}
  layout="constrained"
  width={800}
  height={600}
  alt="…"
/>

The layout prop is what lets it generate srcset and sizes. Sample output:

<img
  src="/_astro/hero.hash.webp"
  srcset="…640w, …750w, …800w, …1080w, …1280w, …1600w"
  sizes="(min-width: 800px) 800px, 100vw"
  loading="lazy"
  decoding="async"
  width="800"
  height="600"
  data-astro-image="constrained">

Here width and height are provided explicitly — but if you import the image, Astro can infer them from the source. Either way, it carries them onto the output <img> and derives an aspect ratio that reserves the space before load. Which cancels CLS, since there’s no unexpected shift in the layout.

Generate once, or on demand

By default, generating the images needs no server. At build time, astro:assets creates static files with hashed names in /_astro/. That means you can deploy anywhere: GitHub Pages, S3, a USB stick.

Adapters take it further. In the project I use the @astrojs/netlify adapter, which unlocks the Netlify Image CDN. Resizing then happens on demand. For a site with hundreds of images, build time isn’t affected, and the CDN caches the files. Other services are available: Cloudinary, Imgix and others have integrations.

Both options have trade-offs:

A small Tailwind gotcha

If you’re used to Tailwind, you may hit a problem with the way Astro handles responsive images. To enable them in Astro, add to the config file (docs):

// astro.config.mjs
image: { responsiveStyles: true }, // default false

This injects global CSS rules deliberately low in specificity via :where([data-astro-image]), which still win over Tailwind classes. That makes your Tailwind classes inert. Good to know before spending ten minutes figuring out why object-cover does nothing. The workaround is to use the fit and position props, which plug straight into those same responsive styles:

<Picture src={img} layout="constrained" fit="cover" position="top" alt="…" />

The other option is to disable responsiveStyles and take full control of the CSS. As of Astro 6, the responsive styles are emitted as a hashed class plus data-astro-fit / data-astro-pos attributes.

Also good to know: from a single image you can generate two crops (a square thumbnail on the grid, a wide landscape cover on the detail page) with no script:

{/* grid: square crop */}
<Picture src={img} layout="constrained" width={400} height={400} fit="cover" alt="…" />

{/* detail cover: landscape, focus on top */}
<Picture src={img} layout="full-width" width={1200} height={600} fit="cover" position="top" alt="…" />

Choosing: you need a benchmark

To decide which strategy wins between auto and manual, then between the others, you need to measure their effectiveness. The results of my first “benchmark” were noisy, counter-intuitive and unusable — which revealed my method was wrong.

The first benchmark

It ran locally: Lighthouse over netlify serve, desktop preset, median of 3 runs, cold cache each run, on already-optimized picsum sources. I kept only these three lines:

StrategyLCP (ms)Transfer (KB)
naive48769400
manual749962
auto1529523

What can we read into it? naive is a disaster: 4876 ms LCP, 9400 KB transferred. Basic. But the rest is downright counter-intuitive: manual, which serves a JPEG less optimal than AVIF and twice as heavy as auto (962 vs 523 KB), gets an LCP twice as good. 🙃

The catch: this benchmark measured two things at once.

In short, I thought I was measuring one variable; I was measuring two. I redid everything on clean foundations. Which was tedious, I won’t hide it.

The criteria that matter

A single LCP column actually blends four variables, which you have to separate before comparing anything:

And above all, I measure mobile and desktop separately, from a set of raw originals committed once (Unsplash photos of 3 to 6 MB, resized and re-encoded at build). The first attempt drew its sources from picsum, already optimized before even entering the pipeline — which flattened the very gaps the strategies exist to show.

One criterion no number can replace remains: the user’s need. A photo tolerates resampling by a few percent; text on an image does not. That’s what determines, upstream, the choice between manual/auto and pixel-perfect — I come back to it in Part 2, in the Pixel-perfect section.

To check hosting’s impact, I re-ran the same benchmark on a static OVH build (files produced by Sharp at build) against Netlify’s on-demand transformation. This is a pass independent of the dual-mode tables below, hence slight differences in bytes (729 vs 716 KB for auto desktop).

StrategyOVH LCP (ms)OVH (KB)Netlify LCP (ms)Netlify (KB)
naive129291586159136
auto414373412729

On the raw payload (naive, ~9 MB, no transformation), only transport matters and Netlify’s edge beats OVH’s single origin by a factor of two: a POP (Point of Presence) close to the visitor crushes a single origin. On optimized assets it’s the reverse: Sharp build-time produces files ~2× smaller than on-demand transformation (373 vs 729 KB), for a near-identical LCP. Hosting can matter, but less than dialing in the sizes values.

The baseline verdict

Let’s cleanly compare the first three strategies with our new method, dual-mode (Mobile, Desktop):

StrategyMobile — bytes (KB) / LCPDesktop — bytes (KB) / LCP
naive9124 / 28.9 s9124 / 605 ms
manual1148 / 6.5 s889 / 375 ms
auto597 / 3.8 s716 / 348 ms

auto wins on both modes — fewer bytes on mobile, better LCP everywhere, without a line of config. It serves as the baseline for the comparisons that follow.

The devtools lesson deserves an explanation, because it’s what sets the new method apart from the old. By default, Lighthouse doesn’t really throttle the network: it lets the page load at full speed, then simulates a slow network with a mathematical model, Lantern. That model estimates how long each request would have taken — an extrapolation that, on a grid of twenty-one competing images, amplified noise non-linearly and blurred the gaps between strategies. Passing --throttling-method=devtools emulates a Moto G Power on a Slow 4G actually applied to the network layer: every byte really travels at the throttled rate.

The difference jumps out on manual: on a genuinely throttled network, bytes are proportional to time. manual’s 1148 KB become 6.5 s of mobile LCP — the exact opposite of what the first attempt’s simulated throttling suggested, where manual looked far faster.

What I Learned

Previous