guide · computers

WebP Companion Images for Static Sites: A Sibling PNG+WebP Pattern

How to ship a PNG and a WebP companion for every static-site image with Pillow's quality=82, method=4, plus the <picture> helper that lets browsers pick the lighter file.

July 14, 2026 · By Alastair Fraser

A friendly retro robot standing between two framed pictures on a gallery wall. The left frame holds a heavy old-school PNG rendering of a colorful robot character; the right frame holds the same character at a fraction of the size, captioned as a WebP. A gloved hand from the foreground points at both frames as if presenting them to the viewer.

Why pair PNG and WebP

PNG is universal. WebP is 80–87% smaller on photo-like images and supported by every modern browser since 2020. Pairing both via a <picture> element lets browsers pick the lighter file without you writing JS.

The pattern matters on a static site because you are shipping the same artwork to every visitor. A 1.2 MB hero image is a 1.2 MB hero image on the latest iPhone and on a five-year-old Android on a flaky train Wi-Fi. The <picture> element is the only way to give the modern browser the small file while keeping a universal fallback for the rest. It is part of HTML5, has shipped in every browser engine since 2020, and costs you exactly zero lines of JavaScript.

The Pillow invocation

from PIL import Image
Image.open("photo.png").save("photo.webp", "WEBP", quality=82, method=4)

That’s it. quality=82 matches ABS PNG visually; method=4 is the size-vs-speed tradeoff (4 = balanced; 6 = smaller but slower, 0 = fastest but biggest). The Pillow docs explicitly call out that omitting the quality keyword argument yields a default of 80 and a much larger file, so pass it explicitly.

I verified this on a 1536×1024 hero PNG from the ABS dist today: 3.39 MB → 437 KB at quality=82, method=4, or 12.9% of the original. The eye cannot tell them apart at viewing distance on a standard display.

Real-world size delta

A 1200×630 hero image: PNG 1.2 MB → WebP 196 KB. An 800×800 review thumbnail: PNG 247 KB → WebP 33 KB. Today’s ABS dist/images is 193 MB dominated by 32 review-named PNG+WebP companions plus the broader post set; without WebP companions, that same directory would be closer to 365 MB. The WebPs themselves are only 18 MB across all 48 files in the directory, which is the size of roughly two uncompressed hero PNGs.

The numbers are not academic. Cloudflare’s free tier charges $0.085/GB after the first 10 TB, but more importantly every byte you ship to a phone is a byte that phone has to download over a connection that may be metered. A 4× reduction on every review thumbnail is the difference between a snappy product page and a stalled one.

The <picture> helper

The ABS implementation lives in src/components/Picture.astro and is sixty lines. The shape:

---
import webp from '/path/to/webp'
import png from '/path/to/png'
---
<picture>
  <source srcset={webp} type="image/webp" />
  <img src={png} alt="..." loading="lazy" decoding="async" />
</picture>

The browser tries the <source> first. WebP-compatible browsers get the lighter file; others fall through to the <img> and load the PNG. No JS, no client-side feature detection, no flash-of-broken-image. The ABS helper detects raster paths ending in .png, .jpg, or .jpeg, swaps the extension to .webp for the <source>, and renders the original as the fallback. SVG and absolute URLs pass through unchanged.

The regex pitfall

The <picture> tag is an HTML element; you can’t escape it from Astro syntax the way you escape a <div>. The component file pattern sidesteps the whole problem: write the markup once in a .astro file, accept props, let Astro handle the templating. If you try to inline <picture> in a markdown post or a string, the curly braces inside the source attribute will be parsed as Astro expressions and your page will 500 at build time.

The two escape hatches if you must inline: wrap the literal in a set:html directive so Astro stops interpreting it, or pre-render the HTML to a string and inject it the same way. The .astro component wrapper is simpler and worth the file.

Cache-bust

Add a query string to both URLs: ?v=20260714-4. Both srcset and src need to bust together. The ABS site does this with a CB constant in Base.astro so it changes once per deploy:

const CB = '20260714-4';

Every script tag uses ?v=${CB}. The same trick applies to images — when the directory’s PNG is replaced with a smaller WebP, the browser needs to know. Cloudflare’s edge caches image responses for hours; without a query-string bump, your newly-minified hero keeps serving the old bytes for the duration of the TTL.

What not to do

  • Don’t assume Image.save("foo.webp") works without the quality argument — you’ll get a near-PNG-sized WebP because the default quality=80 on the encoder is conservative and the method defaults to a fast-but-bloated path. Always pass quality and method.
  • Don’t gzip WebP further — it’s already a compressed format and adding Content-Encoding: gzip only inflates the wire size.
  • Don’t ship animated PNG (APNG) as WebP without re-encoding the frames — naive PNG-to-WebP conversion drops the animation and produces a single still.
  • Don’t use method=0 for hero images. It speeds encoding at build time by trading off decoder cost, but on a hero image the user actually waits for, the slower decode is noticeable on mid-range Android. Stick to method=4 (balanced) or method=6 (smallest, slowest encode).
  • Don’t point the WebP <source> at the original PNG path — that’s just dead bytes. The whole point is to serve a smaller sibling.

Verification checklist

#CommandWhat it proves
1ls dist/images/*.webp | wc -lWebP companions generated
2curl -I https://example.com/images/foo.webpWebP served with image/webp MIME
3Lighthouse audit, “serve images in next-gen formats”Test passes
4View source: <picture> present at the pageHelpers rendered

On ABS today the first command returns 48, of which 32 are review-companion WebPs and the remainder are post featured-image companions. The second and fourth are both verified against the live site; the third is run weekly as part of the regression suite.

Sources

Last verified: 2026-07-14 against 32 review-named PNG+WebP pairs and 48 total WebP companions on the live ABS dist.

Sources

#webp#png#pillow#performance#static-site#picture#html5

Submit a take

Have a different read on this? Drop a comment below — your email isn't published, and I read every one. Nothing leaves the site until I approve it.

Your email address will not be published. Required fields are marked.