Four screenshots: 955 KB down to 105 KB with WebP
The same UI screenshots were 955 KB as PNG and 105 KB as WebP. The 6x compression isn't from lowering quality — screenshots dominated by flat colour compress to a fraction in WebP. Parameters and measurements included.
Lighthouse flagged it: Improve image delivery — Est savings of 537 KiB.
Four screenshots in the case-study section of my homepage. All 1200×777 PNG. 955 KB combined, two thirds of the page's total transfer.
After converting to WebP: 105 KB. An 850 KB saving.
TL;DR
| File | PNG | WebP | Ratio |
|---|---|---|---|
| shot-tool.png | 283 KB | 17 KB | 6% |
| shot-shop.png | 195 KB | 34 KB | 17% |
| shot-shop-en.png | 184 KB | 33 KB | 18% |
| shot-tool-en.png | 291 KB | 20 KB | 7% |
| Total | 955 KB | 105 KB | 11% |
Best case 6%, worst case 18%. The spread comes from image content, explained below.
Why 6% is possible
This isn't brute-forced lossy compression. Look at what's actually in these images:
- Dark background (
#08090b) covering 70%+ - Text in white and a handful of greys
- No photographs, no gradient noise
Large flat areas plus a small palette is exactly what WebP is built for.
PNG compression (DEFLATE) is row-by-row lossless. It's inefficient against "huge regions of identical pixels" — every pixel still carries an index. WebP stores "this whole block is this colour" directly.
For contrast: photographs, complex gradients, and lots of anti-aliased edges land at 40–60% compression. 6% is a flat-colour special case, not a general rule.
The parameters
from PIL import Image
import os
for name in ["shot-shop.png", "shot-tool.png", "shot-shop-en.png", "shot-tool-en.png"]:
im = Image.open(name).convert("RGB")
out = name.replace(".png", ".webp")
im.save(out, "WEBP", quality=82, method=6)
o, n = os.path.getsize(name), os.path.getsize(out)
print(f"{name:22s} {o//1024:>4} KB -> {n//1024:>4} KB ({n*100//o}%)")
Two settings matter:
quality=82 — not arbitrary. UI screenshots show no visible loss between 80 and 85 (slight softening on text edges). Below 75 text starts to blur. Above 90 the file grows with no perceivable gain.
method=6 — trades encoding time for size. Range is 0–6; 6 is slowest and tightest. For four images that's under a second. For hundreds, use method=4.
Verify the compression didn't damage the image
Smaller file doesn't mean correct output:
import numpy as np
from PIL import Image
a = np.array(Image.open("shot-tool.png").convert("RGB")).astype(int)
b = np.array(Image.open("shot-tool.webp").convert("RGB")).astype(int)
diff = np.abs(a - b)
print("max channel delta:", diff.max())
print("mean delta:", diff.mean())
Output: max 14, mean 2.14 (out of 255). The max occurs on anti-aliased text edges — invisible.
Don't skip this step. I once compressed a hairline-diagram image to 20% of its size and the lines developed ringing artifacts. Size alone would have said "success".
Update the references, not just the files
After generating WebP, the src attributes must change:
sed -i 's|/images/shot-tool\.png|/images/shot-tool.webp|g' app/**/page.tsx
If you forget, the browser keeps requesting the old PNG and you've compressed nothing.
I did forget once: files converted, src untouched, re-ran Lighthouse, score didn't move. Half an hour wasted.
The Next.js Image trap
If you use <Image>, watch unoptimized:
<Image src="/images/shot-tool.webp" width={1200} height={777} unoptimized />
With unoptimized, Next.js emits the file as-is — no format conversion, no multi-size variants, no hashing.
Add it when:
- Images are already optimised before build (this case)
- Static export (
output: 'export') without an image service
Don't add it when:
- You want automatic DPR-based variants
- Source images come from user uploads with unknown dimensions
I kept unoptimized here: WebP is already at the limit, Next re-compressing adds little and another cache key.
Icons in the same pass
| File | Before | After | Method |
|---|---|---|---|
| favicon.ico | 20.6 KB | 2.8 KB | 16/32 only |
| apple-touch-icon.png | 39 KB | 14 KB | 180×180, 128 colours |
| logo-192.png | 44 KB | 15.8 KB | same |
The 20 KB favicon.ico held 16/32/48/64. Browsers mainly use 16 and 32; dropping the bigger sizes cut it to 2.8 KB:
src = Image.open("logo-512.png").convert("RGBA")
src.save("favicon.ico", format="ICO", sizes=[(16,16), (32,32)])
Note the .convert("RGBA") — saving an RGB image as ICO makes Next.js fail the build with The PNG is not in RGBA format!.
End result
| Metric | Before | After |
|---|---|---|
| Four screenshots | 955 KB | 105 KB |
| Page transfer | ~1.5 MB | 424 KiB |
| Lighthouse image audit | 537 KiB savings flagged | passes |
| Performance score | 93 | 97 |
No quality loss, no layout change. Just a different container.
When not to do this
| Type | Advice |
|---|---|
| UI screenshots, charts, flat illustration | WebP wins big (this case) |
| Photos, complex gradients | 40–60%, still worth it |
| Needs alpha + lossless | PNG or WebP lossless — don't set quality |
| Icons, simple geometry | SVG, an order of magnitude smaller |
| Social/OG images | Keep JPG/PNG — WebP support in link unfurlers is still patchy |
That last one bites: an og:image in WebP renders as a blank card on some platforms.
One line
Look at what the image is made of before picking a format. Flat-colour screenshots go below 10% in WebP; photographs stop around half — both beat shipping the PNG.
Does your site have these problems?
Run the free audit tool for a 30-second report, or just write to me and describe the situation — I will tell you whether it is worth fixing.