By every rule-of-thumb metric, my site was fast. Static Astro, 15.5KB of JavaScript total — one file — and 73KB of CSS. The kind of numbers that make you stop checking. Then I actually measured the production build and found it was shipping 3.37MB of images. Roughly 90% of everything a visitor could download was image bytes, and three files accounted for almost all of it.
The interesting part isn’t that the images were big. It’s that each of the three got past Astro’s image optimization in a different way — and none of them produced a warning, a build error, or a visibly broken page. A fast framework with an image pipeline is not the same thing as a fast site. Here’s how each one slipped through, and the 15-line fix.
The one-liner that found the problem
The mistake I’d been making was looking at src/. What visitors download is dist/, so that’s what you measure:
bun run build
find dist -type f \( -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' \
-o -name '*.webp' -o -name '*.avif' \) -exec du -b {} + | sort -rn | headMine came back with three offenders sitting on top:
| File | Size in dist/ | What it was |
|---|---|---|
ubuntu-cycling-camp-landingpage.png | 1.66MB | A portfolio screenshot, shipped raw |
me.jpg | 934KB | A full-resolution camera photo rendering as a ~500px avatar |
hero-image.png | 539KB | Template boilerplate that nothing referenced |
Each of these is a different failure mode, and each is worth understanding, because your site almost certainly has at least one of them.
Failure mode #1: the pipeline never shrinks your original
me.jpg was a photo straight off the camera: 3456×4608 pixels, 934KB, rendering as an avatar around 500px wide. And here’s the thing — Astro was doing its job. The build generated WebP variants at several widths and wired them into a proper srcset. Modern browsers picked a small variant. Looked like a solved problem.
But the original file still shipped, byte for byte. Astro’s image pipeline builds variants below your source; it never shrinks the fallback itself. The src a browser falls back to — and the URL scrapers, older clients, and some in-app webviews actually fetch — was my 934KB original. I verified this on the current build: the hashed me.jpg in dist/ is byte-identical to the source file.
The rule that follows: your source file is the floor of what a visitor might download. No downstream optimizer changes that. If the source is 10× the largest size you render, you’re always one fallback away from serving 10× the bytes. On my homepage that avatar loads eagerly — it’s the LCP element — so the floor was load-bearing.
Failure mode #2: the screenshot that shipped raw
The portfolio screenshot was a 1898×1027 PNG at 1.66MB, and unlike the avatar, the pipeline gave it nothing: no variants, no WebP, no srcset. It went from src/assets/ into dist/ untouched, and every visitor got all 1.66MB of it.
This is the silent one. The page rendered perfectly. There’s no build warning for “this image bypassed optimization.” You only catch it by measuring dist/, which is exactly why the one-liner above is the whole diagnostic. It’s the same genus of problem as the Partytown trap that silently killed my analytics — a theme wiring assumption you can’t see from the rendered page.
Screenshots at that resolution are also close to PNG’s worst case: photographic gradients, browser chrome, anti-aliased text. PNG’s lossless encoding pays for pixels you’ll never appreciate at display size. The same screenshot at 1600px wide, encoded as WebP at quality 80, is 90KB — and the dates, buttons, and stats in the screenshot are still fully legible.
Failure mode #3: the 539KB nobody asked for
hero-image.png came with the theme. Nothing in my code referenced it — not a page, not a component. It shipped anyway, because the theme’s asset handling bundles what’s in the images directory, referenced or not.
This one doesn’t even need Sharp. Deleting an unused file beats optimizing it every time. If you built on a template — AstroWind, a starter, anything — check what demo assets are still riding along in your production build. Mine had been shipping over half a megabyte of boilerplate to every visitor for months.
The fix: 15 lines of Sharp, zero new dependencies
Sharp was already in my package.json — Astro’s own image service uses it — so the fix costs no new dependencies. A one-shot script, run once with bun, then deleted. This is a migration, not build tooling:
import sharp from 'sharp';
// 3456×4608 camera photo → 900px: ~2× the rendered width, retina headroom
await sharp('src/assets/images/me.jpg')
.rotate() // bake in EXIF orientation BEFORE resizing — see note below
.resize({ width: 900 })
.jpeg({ quality: 82, mozjpeg: true })
.toFile('src/assets/images/me-900.jpg');
// 1898px PNG screenshot → 1600px WebP
await sharp('src/assets/images/ubuntu-cycling-camp-landingpage.png')
.resize({ width: 1600 })
.webp({ quality: 80 })
.toFile('src/assets/images/ubuntu-cycling-camp-landingpage.webp');Three gotchas worth the ink:
.rotate()with no arguments is not optional for phone photos. Sharp strips metadata on output by default, including the EXIF orientation tag. If you resize without.rotate(), the tag that told browsers “display this sideways image rotated” is gone — and your photo lands on the page sideways..rotate()bakes the orientation into the pixels first.- Sharp refuses to write over its input file (
Cannot use same file for input and output). Write to a new name, then rename over the original. mozjpeg: trueswitches Sharp’s JPEG encoder to mozjpeg settings — a meaningfully smaller file at the same visual quality, for free.
Then one reference update: the component that showed the screenshot pointed at .png, now it points at .webp. That was the entire code diff.
The results, verified in the build
| File | Before | After | Δ |
|---|---|---|---|
me.jpg | 934KB (3456×4608) | 86KB (900px, mozjpeg q82) | −91% |
ubuntu-cycling-camp-landingpage | 1.66MB (raw PNG) | 90KB (1600px WebP q80) | −95% |
hero-image.png | 539KB | deleted | −100% |
Total images in dist/ | 3.37MB | 382KB | −88% |
The after-numbers aren’t estimates — they’re the same find-on-dist/ measurement, re-run on the shipped build. The homepage’s LCP resource went from 934KB to 86KB plus properly picked WebP variants; on a mid 4G connection that’s the difference between the largest content element arriving in hundreds of milliseconds versus several seconds. JavaScript was never the problem on this site. Images were the entire problem.
Check the pixels, not just the byte count
One step I’d insist on: open the before and after files side by side before you commit. Byte counts don’t tell you whether quality 80 smeared the text in your screenshot. For mine, I zoomed into the densest region — dates, small stat labels, button text — and confirmed it was all legible. The avatar I checked on a retina display, where an under-sized source shows up first as soft edges.
If the text had smeared, the fix is quality 85 or a wider resize — the difference between 90KB and 120KB is nothing when you started at 1.66MB. The budget you’re protecting is the megabyte, not the last kilobyte.
The checklist
- Measure
dist/, notsrc/— the build output is what visitors download. Thefind | sortone-liner is the whole audit. - Your source file is the floor. Resize sources to ~2× their largest rendered width before they enter the repo. The pipeline optimizes downward from your source; it never fixes the source itself.
- Screenshots become WebP. A quality-80 WebP at display width replaces a multi-megabyte PNG with no legibility loss.
- Delete template assets you don’t use. The cheapest optimization is a removed file.
- Eyeball the output at real display size before committing.
- Re-run the measurement after every content change — I’ve folded it into the validation pass every post gets before it ships.
Frameworks earn their reputation for being fast, and Astro deserves it — 15KB of JavaScript across a whole site is remarkable. But the pipeline only optimizes what flows through it, downward from whatever source you hand it. Hand it a camera original and it will faithfully build small variants while the 934KB original ships as the floor. The fix took 15 lines and one afternoon. The measuring took one command. Run the command.