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.

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 thequalityargument — you’ll get a near-PNG-sized WebP because the defaultquality=80on the encoder is conservative and themethoddefaults to a fast-but-bloated path. Always passqualityandmethod. - Don’t gzip WebP further — it’s already a compressed format and adding
Content-Encoding: gziponly 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=0for 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 tomethod=4(balanced) ormethod=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
| # | Command | What it proves |
|---|---|---|
| 1 | ls dist/images/*.webp | wc -l | WebP companions generated |
| 2 | curl -I https://example.com/images/foo.webp | WebP served with image/webp MIME |
| 3 | Lighthouse audit, “serve images in next-gen formats” | Test passes |
| 4 | View source: <picture> present at the page | Helpers 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
- Pillow WEBP docs
- MDN picture element
- ABS companion: ABS Regression Failure Modes
Last verified: 2026-07-14 against 32 review-named PNG+WebP pairs and 48 total WebP companions on the live ABS dist.

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.