Your hero image is lazy, and your WebP is bigger than the PNG

Personal · No. 062

Shipped

This release is an audit pass on the site you are reading: security headers, sitemap lastmod dates, structured data, and a set of image performance fixes. The image work is the part worth a full walkthrough, because my first attempt at it made things worse in a way the usual advice does not warn you about. This guide covers finding the problem, generating WebP variants at build time with sharp, and the measurement step that should come before any encoding choice.

Find out what your images are actually doing

Static site generators make it easy to slap loading="lazy" on every image and feel good about it. Grep your built output before trusting that feeling:

grep -o 'loading="lazy"' dist/index.html | wc -l
grep -o 'loading="eager"' dist/index.html | wc -l
      62
       0

That was this site: every image lazy, including the big cover image at the top of the page. That top image is almost certainly your Largest Contentful Paint element, and lazy-loading it is a known anti-pattern. The browser will not even discover a lazy image until layout settles, then fetches it at low priority. Google’s study of WordPress sites measured median LCP improving 15% on mobile just from not lazy-loading above-the-fold images.

The fix is two attributes on the hero only:

<img src="/covers/hero/my-post.webp" alt=""
     loading="eager" fetchpriority="high"
     decoding="async" width="1600" height="900" />

fetchpriority="high" tells the browser to start the request at high priority immediately instead of upgrading it after layout. Google Flights measured LCP dropping from 2.6 s to 1.9 s from this attribute alone. Keep loading="lazy" on everything below the fold; the same WordPress study showed lazy-loading the rest still saves 50 to 70% of image bytes on initial load.

Measure encodings before you convert anything

The standard advice says convert PNGs to WebP and enjoy the savings. I almost shipped that advice without checking it. Install sharp (npm install sharp) and drop a small script in your project root; it has to live where sharp is installed, because Node resolves imports from the script’s location, not your shell’s:

// measure.mjs
import sharp from 'sharp';
import { statSync } from 'node:fs';

const src = process.argv[2];
const results = {
  'source png': statSync(src).size,
  'webp q80': (await sharp(src).webp({ quality: 80 }).toBuffer()).length,
  'webp lossless': (await sharp(src).webp({ lossless: true }).toBuffer()).length,
  'thumb 640 webp q80': (await sharp(src).resize({ width: 640 }).webp({ quality: 80 }).toBuffer()).length,
  'thumb 640 png': (await sharp(src).resize({ width: 640 }).png({ palette: true }).toBuffer()).length,
};
for (const [name, bytes] of Object.entries(results)) {
  console.log(name.padEnd(20), (bytes / 1024).toFixed(1) + ' KB');
}
node measure.mjs content/covers/v0.1.0.png

Here is the run against one of this site’s cover images, a 1600×900 flat-color graphic:

source png           26.2 KB
webp q80             37.1 KB
webp lossless        17.9 KB
thumb 640 webp q80   12.7 KB
thumb 640 png        23.0 KB

Read that middle line again. Lossy WebP at sharp’s default quality came out 42% larger than the PNG it was supposed to replace. Lossy WebP is tuned for photographs; on flat art with hard edges and few colors, PNG’s lossless compression already does well, and lossy encoding spends bytes fighting the format’s own noise. Lossless WebP is the right tool here: Google’s own lossless study found it beats even size-optimized PNGs by 23% across a 12,000-image web corpus, and on my covers it came in around 30% smaller. For the 640px thumbnails, lossy q80 wins because the resize has already destroyed the hard edges that made lossy a bad fit.

So the pipeline this site shipped: lossless WebP for the full-size hero, q80 WebP for thumbnails. Your images may point the other way; the script costs you a minute.

Generate the variants at build time

Astro can emit these as static files with an endpoint route, no image service and no runtime cost. Put your source PNGs in content/covers/, then create src/pages/covers/hero/[slug].webp.ts:

import type { APIRoute } from 'astro';
import { readdirSync } from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';

const COVERS_DIR = path.join(process.cwd(), 'content/covers');

export function getStaticPaths() {
  return readdirSync(COVERS_DIR)
    .filter((f) => f.endsWith('.png'))
    .map((f) => ({ params: { slug: f.replace(/\.png$/, '') } }));
}

export const GET: APIRoute = async ({ params }) => {
  const webp = await sharp(path.join(COVERS_DIR, `${params.slug}.png`))
    .webp({ lossless: true })
    .toBuffer();
  return new Response(new Uint8Array(webp), {
    headers: { 'Content-Type': 'image/webp' },
  });
};

The thumbnail route at src/pages/covers/thumb/[slug].webp.ts is the same shape with a resize and the lossy encoder:

import type { APIRoute } from 'astro';
import { readdirSync } from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';

const COVERS_DIR = path.join(process.cwd(), 'content/covers');

export function getStaticPaths() {
  return readdirSync(COVERS_DIR)
    .filter((f) => f.endsWith('.png'))
    .map((f) => ({ params: { slug: f.replace(/\.png$/, '') } }));
}

export const GET: APIRoute = async ({ params }) => {
  const webp = await sharp(path.join(COVERS_DIR, `${params.slug}.png`))
    .resize({ width: 640 })
    .webp({ quality: 80 })
    .toBuffer();
  return new Response(new Uint8Array(webp), {
    headers: { 'Content-Type': 'image/webp' },
  });
};

In your templates, serve the WebP through <picture> with the PNG as fallback, so nothing breaks for feed readers and ancient browsers that hotlink the original:

<picture>
  <source type="image/webp" srcset="/covers/hero/my-post.webp" />
  <img src="/covers/my-post.png" alt=""
       loading="eager" fetchpriority="high"
       decoding="async" width="1600" height="900" />
</picture>

Cache the variants forever, in the right place

Published images never change, so they should be immutable in the browser cache. On Cloudflare Pages that means a _headers file, and the prefix you choose matters more than it looks:

/covers/*
  Cache-Control: public, max-age=31536000, immutable

The splat is safe only because nothing but images lives under /covers/. The obvious rule would have targeted the existing image path, which sat under the same prefix as the HTML pages; that splat would have marked every article page immutable for a year. This site grew a dedicated /covers/ prefix for exactly this reason. If your images share a prefix with pages, move the images. MDN’s guidance on immutable is the same idea: reserve it for URLs whose content can never change, and version the URL when the content does.

Build and check the output; you should see one .webp per source PNG:

npm run build
ls dist/covers/hero | head -3
first-post.webp
second-post.webp
third-post.webp

Verify it on the live site

After deploying, check what actually serves. Three curl commands cover the whole feature:

curl -sI https://yoursite.com/covers/hero/my-post.webp | grep -iE 'HTTP|content-type|cache-control'

You should see a 200 with content-type: image/webp and your immutable cache policy. When I ran this against the live site, Cloudflare had folded its own must-revalidate into the line, so match on substrings rather than the exact string:

HTTP/2 200
content-type: image/webp
cache-control: public, max-age=31536000, must-revalidate, immutable

And confirm the hero markup survived your framework’s build:

curl -s https://yoursite.com/ | grep -o 'fetchpriority="high"'

You should see one match: the hero, and only the hero.

Gotchas

Lossy WebP can be bigger than your PNG. The trap: converting flat-color graphics (screenshots, diagrams, generated card images) to lossy WebP because the format is “smaller”. The symptom: your build output grows and nobody notices, because who audits the optimized files? Mine were 37 KB WebP next to 26 KB PNG. The escape: run the measurement script on your real images first, and use lossless: true for graphic art. Full-size covers on this site went from 26 KB to 18 KB that way.

Response headers in prerendered endpoint code do nothing. The trap: setting Cache-Control in the Response object of a static endpoint, like the one above, and trusting it. The symptom: production serves your default site-wide caching policy instead, because for prerendered routes the endpoint runs at build time; only the file’s bytes survive to deploy. Cloudflare’s docs are explicit that _headers rules govern static assets, and header code only matters for responses generated at request time. The escape: put caching policy for static routes in _headers and treat in-code headers on prerendered endpoints as dead weight.

Your verification can read stale edge cache and gaslight you. The trap: curling the live site seconds after a deploy to confirm the fix. The symptom: half your checks pass and half show the old content; my first post-deploy check showed the new security headers live while the sitemap still looked unfixed, and a deleted file still returned 200. The escape: add a cache-busting query string (?cb=1) to force an origin fetch, and read the cf-cache-status header to see whether you got an edge copy. The stale entries revalidate away on their own; your checks just have to outrun them.

Sources