development
I Shipped a 7MB Hero. The Cleanup, and the Fix You Should Actually Do
I asked an AI to draw me a pixel-art hero for my last article. What came back was lovely — a blocky, retro little scene, exactly what I’d asked for. It was also 7.1 megabytes, two thousand seven hundred pixels wide, and a PNG.
I dropped it in public/images/, where every other hero on this site lives. I assumed Astro would compress it on build.
Astro did not.
Why this matters (a note to Dear Reader)
Not to save bandwidth. Not to please a Lighthouse score. The reason is simpler.
You — Dear Reader — clicked through to read this. You gave me your attention. The least I can do is not punish you for it. A 7MB hero on a phone, on a slow connection, in a coffee shop with patchy Wi-Fi, costs you time, data, and patience before you’ve read a word. A 200KB hero loads instantly and gets out of the way. One of those experiences is worse than the other. There’s nothing more to it.
This isn’t about saving money on bandwidth — that’s a rounding error. It’s about absorbing the complexity on my side so reading my work doesn’t suck on yours. That’s the trade. It’s a small ethical one, and the rest of this article is one specific way of holding up my end of it.
The misconception
Astro 5 has an excellent image pipeline. WebP at quality 80 is the default. It generates srcset variants. It strips metadata. It does the right thing — to images you import from src/.
For files in public/, it does precisely none of that.
public/ is the static-passthrough directory. Files in there are served byte-for-byte, no transforms. Most starter content collections — including this site’s — define hero images as a string path that lets you point anywhere, including public/:
image: z.object({
src: z.string(),
alt: z.string(),
}).optional(),
Friendly, familiar, and a footgun. The friendlier the API, the easier it is to opt out of the smart thing without realising.
The thirty-second patch
I needed a fix now, not a refactor. Two tools, both already on a Mac, both excellent at this:
sips— built in. No install.cwebp—brew install webp. Slightly better quality at the same byte budget.
The actual command from my cleanup:
cwebp -q 82 -resize 1600 0 hero.png -o hero.webp
Top to bottom: quality 82 (the sweet spot — pixel art and photographs both survive); resize to 1600 pixels wide with proportional height (0 means “keep aspect ratio”); input PNG; output WebP.
Why 1600px? My article container caps at 800px. Doubled for retina screens, that’s 1600. Anything wider is bytes you’re paying for and the user never sees.
The numbers from this one image: 7.1MB → 211KB. A 97% reduction. Most of that is WebP doing its job; the rest is throwing away pixels nobody was going to look at.
Update the frontmatter — .png to .webp — delete the original, ship. Done.
The fix you should actually do
That’s the patch. The real fix is to make this category of mistake impossible.
Schema change in src/content.config.ts. Convert the schema to a function so it receives Astro’s image() helper:
const articles = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/data/articles' }),
- schema: z.object({
+ schema: ({ image }) => z.object({
// ...
image: z.object({
- src: z.string(),
+ src: image(),
alt: z.string(),
}).optional(),
}),
});
Layout swap in src/layouts/ArticleLayout.astro (and CaseStudyLayout.astro — same pattern). Replace the raw <img> tag with Astro’s <Image> component:
+ import { Image } from 'astro:assets';
...
- <img class="hero-image" src={image.src} alt={image.alt} />
+ <Image
+ class="hero-image"
+ src={image.src}
+ alt={image.alt}
+ widths={[800, 1200, 1600]}
+ sizes="(min-width: 1024px) 800px, 100vw"
+ />
Move the images. Heroes go from public/images/ to src/assets/articles/, and frontmatter paths become relative (../../assets/articles/hero.png) so the import resolves.
Once that’s in place, every future hero is converted to WebP, emitted with multi-width srcset variants, served at the right size for the device. You literally cannot ship a 7MB PNG anymore — the build catches it.
”But WordPress does this automagically”
I use WordPress and this just happens. Why do I need any of this Astro nonsense?
Fair question. Sort of.
Image optimisation in WordPress depends on a plugin — Smush, ShortPixel, EWWW, whatever’s in fashion this year. You install it, configure it, sometimes pay for it, and it sits in your stack quietly compressing things. That’s a real win, and I won’t pretend otherwise.
Here’s the trade.
Every plugin is code someone else writes that runs inside your site, with access to your media library and often your database. When the author stops maintaining it — or ships a security regression two updates from now — that’s your problem. The automagic comes with a long-term liability. Multiply by the dozen-or-so plugins a typical WordPress site accumulates and you’ve grown a maintenance surface you never chose. Add the consultant who set the whole thing up eighteen months ago and is no longer answering emails, and you’ve got a stack that nobody alive fully understands.
What I’m doing here with Astro is the same job done with standards code in my own repo. sharp, WebP, an <Image> component — version-controlled, readable, no third party in the chain. No subscription. No update-or-die. No undocumented consultant magic. The trade is that I do the work once.
And here’s the part that makes it a non-decision: this is exactly the kind of small, well-scoped task an LLM is excellent at. Paste this article into Claude, point it at your repo, and the migration is a five-minute conversation. The skill ceiling for “do it yourself in standards” used to be high. It isn’t anymore.
Own the boring parts. Lean on LLMs for the labour. Owe nothing to a plugin ecosystem.
The lesson
public/ is for byte-perfect static assets: favicons, robots.txt, downloadable PDFs, vendor scripts — things that must not be touched. Heroes are not that. They want optimisation. They belong in src/.
The lesson generalises: friendlier-than-needed APIs are how systemic problems hide. A schema that accepts any string is welcoming, but it’s also silent when you point it at the wrong place.
Close
Like the jq article, the moral is the same: small tools, used in the right place, save the people downstream from you a lot of friction. cwebp is the patch. The schema change is the cure. Dear Reader pays neither tax.
Go generate something beautiful. Just don’t make Dear Reader carry it.