The Complete Image Compression Guide (2026): Lossless & Lossy in the Browser
Why image compression matters
HTTP Archive 2026: images account for 45% of page weight. A 4000×3000 photo can exceed 5 MB — WebP (quality=0.8) typically 200–400 KB, visually identical.
This goes deep into pixel-level principles, format tradeoffs, browser APIs, multi-threading, and CI/CD.
Lossy vs lossless
Lossless
- PNG: DEFLATE + row filters. Great for flat colors, weak for photos.
- WebP lossless: 26% smaller than PNG.
- AVIF lossless: ~22% better than WebP lossless (AV1 intra-frame coding).
Lossy
- Chroma subsampling (4:2:0): halve color resolution, 33% savings, imperceptible.
- Quantization: DCT coefficients into bins. quality = quantization step.
- Predictive coding: AVIF 67 prediction modes vs WebP 4 — hence higher compression.
Format selection
| Format | Lossy/Lossless | Alpha | Anim | Coverage | vs JPEG |
|---|---|---|---|---|---|
| JPEG | Lossy | ❌ | ❌ | 99.9% | Baseline |
| PNG | Lossless | ✅ | ❌ | 99.9% | Worst photo |
| WebP | Both | ✅ | ✅ | 97% | 25–35% smaller |
| AVIF | Both | ✅ | ✅ | 93% | 50% smaller |
Rules: Photos→WebP/AVIF lossy 0.75–0.85. Icons→PNG/SVG. Transparency+photo→WebP/AVIF. Animation→WebP/AVIF (80%+ smaller than GIF).
Canvas + toBlob
async function compressImage(file: File, opts = {}): Promise<Blob> {
const { maxWidth=1920, quality=0.8, format="image/webp" } = opts;
const img = await createImageBitmap(file);
let { width, height } = img;
if (width > maxWidth) { height = Math.round(height*maxWidth/width); width = maxWidth; }
const c = document.createElement("canvas");
c.width=width; c.height=height;
const ctx = c.getContext("2d")!;
if (format==="image/jpeg") { ctx.fillStyle="#FFF"; ctx.fillRect(0,0,width,height); }
ctx.drawImage(img,0,0,width,height);
return new Promise((res,rej)=>c.toBlob(b=>b?res(b):rej(),format,quality));
}
Why resize first?
4000→750 (2x retina for 375px phone) = 95%+ direct savings — beats any algorithm.
Quality tips
- WebP: 0.7–0.85 (blocking artifacts below 0.6).
- AVIF: 0.4–0.6 (non-linear 0–63 scale).
- JPEG: 0.75–0.9 (diminishing returns above 0.92).
Web Worker multi-threading
// compress.worker.ts
self.onmessage = async (e) => {
const blob = await compressImage(e.data.file, e.data.options);
const buf = await blob.arrayBuffer();
self.postMessage({ buf, type: blob.type }, [buf]);
};
const workers = Array.from({length: navigator.hardwareConcurrency-1||1},
() => new Worker(new URL("./compress.worker.ts", import.meta.url)));
Pre-upload compression
async function handleUpload(files: FileList) {
const pool = Array.from({length: navigator.hardwareConcurrency-1||1},
() => new Worker(new URL("./compress.worker.ts", import.meta.url)));
const results = await Promise.all(Array.from(files).map(async (file, idx) => {
if (file.size<50*1024 && file.type==="image/png") return file;
const compressed = await new Promise<Blob>(r => {
pool[idx%pool.length].onmessage = e => r(new Blob([e.data.buf], {type:e.data.type}));
pool[idx%pool.length].postMessage({file, options:{maxWidth:1920,quality:0.78,format:"image/webp"}});
});
return new File([compressed], file.name.replace(/\.[^.]+$/,".webp"), {type:"image/webp"});
}));
await uploadToServer(results);
pool.forEach(w=>w.terminate());
}
CI/CD (Sharp)
const sharp = require("sharp");
await sharp(f).resize(1920).webp({quality:80,effort:4}).toFile(out);
- run: node scripts/compress-images.mjs
AVIF: 3 footguns
- Very slow encode: 2–5s/image (0.3s for WebP).
- No progressive: blank until fully loaded.
- 93% coverage:
<picture>fallback needed.
<picture>
<source srcset="photo.avif" type="image/avif">
<source srcset="photo.webp" type="image/webp">
<img src="photo.jpg" alt="" loading="lazy">
</picture>
Advanced
Tiered quality: hero=0.85/1600, thumb=0.7/400, bg=0.6/40+CSS blur. LQIP: 10×10 placeholder → full. pHash dedup: Hamming<10 → reuse.
FAQ
Q1: toBlob null? — Canvas 0×0 or CORS missing. img.crossOrigin="anonymous".
Q2: WebP/AVIF quality same? — No. WebP 80 ≈ AVIF 50 (SSIM). AVIF quality = 60–70% of WebP.
Q3: PNG photo bigger? — Lossless fails on high-frequency. Always use lossy for photos.
Q4: toBlob vs toDataURL? — Always toBlob. toDataURL = Base64 +33% + all-at-once memory.
Q5: Privacy? — All local. No data leaves the browser during compression.
Conclusion
Strategy: resize → format → client pre-compress → server fallback → tiered quality.