Image Compression for Developers
Images account for roughly 50% of the average web page's weight. Compress them well and your site loads faster, your hosting bill drops, and your Core Web Vitals improve. Compress them poorly and users stare at blank rectangles on 3G connections. This guide covers what actually matters for developers.
Format selection in 2026
The format landscape has stabilised. AVIF has the best compression ratio and is supported by all major browsers. WebP is the safe middle ground — slightly less efficient than AVIF but with broader legacy support. JPEG remains the universal fallback. PNG is for lossless needs only.
The practical approach is to generate multiple formats and let the browser choose. The HTML picture element handles this cleanly:
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img src="hero.jpg" alt="..." width="1200" height="630"
loading="lazy" decoding="async">
</picture>The browser evaluates sources top-to-bottom and picks the first one it supports. No JavaScript, no user-agent sniffing, no content negotiation headers. It just works.
Quality settings that actually matter
Quality is not linear. JPEG quality 100 vs 95 produces a file 2-3x larger with a difference invisible to the human eye. Quality 85 vs 80 saves another 20-30% with artifacts only visible at pixel-level zoom. Below 70, compression artifacts become noticeable in normal viewing.
For AVIF, the quality scale is different. AVIF quality 60 is roughly equivalent to JPEG quality 85 in visual terms, but produces a file 30-40% smaller. When setting up your compression pipeline, do not transfer JPEG quality numbers to AVIF — test visually and calibrate per format.
One quality setting does not fit all images. A photograph with smooth gradients compresses differently than a screenshot with sharp text. If your build pipeline supports per-image configuration, use it. If not, quality 80 for JPEG and quality 60 for AVIF are reliable defaults that work well across image types.
Responsive images: stop serving 2000px to phones
A 2000px-wide hero image is 400KB as JPEG. The same image at 800px is 80KB. On a 375px mobile screen, the 2000px version looks identical to the 800px version — but costs 5x more bandwidth and takes 5x longer to decode.
Generate 3-4 sizes during your build: 400w, 800w, 1200w, and the original size. Use srcset to let the browser pick the right one:
<img srcset="hero-400.jpg 400w,
hero-800.jpg 800w,
hero-1200.jpg 1200w,
hero-2000.jpg 2000w"
sizes="(max-width: 600px) 100vw,
(max-width: 1200px) 80vw,
1200px"
src="hero-1200.jpg"
alt="..." width="2000" height="1050"
loading="lazy" decoding="async">The sizes attribute tells the browser how wide the image will be at each viewport width, so it can calculate which source to download before the CSS has loaded. Without sizes, the browser defaults to assuming the image is full-viewport width, which often means downloading an oversized version.
Build-time compression with sharp
sharp is the most widely-used Node.js image processing library. It wraps libvips, which is significantly faster than ImageMagick for batch operations. A basic compression script processes hundreds of images in seconds:
const sharp = require('sharp');
const glob = require('glob');
const files = glob.sync('src/images/**/*.{jpg,png}');
for (const file of files) {
// Generate AVIF
await sharp(file)
.resize(1200, null, { withoutEnlargement: true })
.avif({ quality: 60 })
.toFile(file.replace(/\.(jpg|png)$/, '.avif'));
// Generate WebP
await sharp(file)
.resize(1200, null, { withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(file.replace(/\.(jpg|png)$/, '.webp'));
// Optimise original JPEG
await sharp(file)
.resize(1200, null, { withoutEnlargement: true })
.jpeg({ quality: 85, mozjpeg: true })
.toFile(file.replace(/\.(jpg|png)$/, '-opt.jpg'));
}The mozjpeg option enables Mozilla's JPEG encoder, which produces 5-10% smaller files than the default libjpeg. The withoutEnlargement flag prevents upscaling images that are already smaller than 1200px.
Client-side compression with Canvas API
When you need to compress images in the browser — before uploading to a server, for instance — the Canvas API handles it natively. No libraries needed, though they can simplify the code:
async function compressImage(file, maxWidth = 1200, quality = 0.8) {
const img = new Image();
const url = URL.createObjectURL(file);
await new Promise(r => { img.onload = r; img.src = url; });
const scale = Math.min(1, maxWidth / img.width);
const canvas = document.createElement('canvas');
canvas.width = img.width * scale;
canvas.height = img.height * scale;
canvas.getContext('2d').drawImage(img, 0, 0,
canvas.width, canvas.height);
URL.revokeObjectURL(url);
return new Promise(resolve =>
canvas.toBlob(resolve, 'image/jpeg', quality));
}This approach resizes and recompresses in one step. The quality parameter ranges from 0 to 1 (0.8 = 80%). For AVIF output, use 'image/avif' as the MIME type — supported in Chrome 96+ and Firefox 113+. Check support with canvas.toBlob() in a try-catch before offering AVIF to users.
CI/CD integration
Automating image compression in your deployment pipeline ensures that no uncompressed image ever reaches production. Add the sharp script above as a build step, or use a dedicated tool like imagemin-cli for simpler setups.
In GitHub Actions, add a step after checkout that installs sharp and runs your compression script. Cache the node_modules directory to keep build times fast. The compressed images get committed to the build output (not to your source repo — keep originals in source).
For teams, consider adding an image size check as a PR gate. A simple script that flags any new image over 500KB prevents the "someone added a 4MB hero image" problem that haunts every long-running project.
Framework-specific approaches
Next.js: The built-in Image component handles resizing and format conversion at request time. It generates WebP and AVIF on-the-fly, caches the results, and serves the right format based on the Accept header. For static exports, use next-image-export-optimizer to pre-generate at build time.
Astro: The astro:assets integration uses sharp under the hood. Import images in your components and Astro generates multiple formats and sizes automatically.
Static sites (Hugo, 11ty, Jekyll): Use a build-time compression script with sharp or imagemin. These generators do not process images natively, so you handle it in your build pipeline.
Performance impact: the numbers
Uncompressed images are the single largest drag on web performance. Here is what proper image compression typically achieves:
- LCP improvement: 30-60% faster when hero images are properly compressed and sized
- Page weight reduction: 40-70% smaller total page size
- Bandwidth savings: For a site serving 1M pages/month with 5 images each, switching from uncompressed JPEG to AVIF saves roughly 2-3 TB/month of transfer
- Mobile impact: On a 3G connection, a 500KB image takes ~5 seconds to load; a properly compressed 100KB version takes ~1 second
The checklist
For any production website, make sure you are doing all of these:
- Serve AVIF with WebP and JPEG fallbacks using the picture element
- Generate responsive sizes (400w, 800w, 1200w minimum) and use srcset
- Set explicit width and height attributes to prevent layout shift (CLS)
- Use loading="lazy" for below-fold images, loading="eager" for the hero
- Add fetchpriority="high" to the LCP image
- Strip unnecessary EXIF metadata (location data, camera info) for privacy
- Automate compression in your build pipeline so nothing ships unoptimised
Images are the lowest-hanging fruit in web performance. A few hours of setup in your build pipeline pays off on every page load, for every user, forever.
Frequently asked questions
More from the blog
Compress, convert, and resize images in your browser. Nothing gets uploaded.
Open MiniPx →