Image and Asset Optimization: Sharp, Squoosh, and Modern Formats
Image and Asset Optimization: Sharp, Squoosh, and Modern Formats
Images are typically the heaviest assets on a web page -- often 50-80% of total page weight. A single unoptimized hero image can be larger than all your JavaScript and CSS combined. Yet image optimization is routinely left as an afterthought, handled with a quick "save for web" in Photoshop or not handled at all.
Modern image formats (WebP, AVIF) deliver dramatically better compression than JPEG and PNG. Build-time optimization tools can process images automatically without manual intervention. Responsive images serve appropriately sized files to different devices. Together, these techniques can cut image payload by 50-80% with no visible quality loss.
Modern Image Formats
The Format Landscape
| Format | Compression | Quality | Browser Support | Best For |
|---|---|---|---|---|
| JPEG | Lossy | Good at 80-85% | Universal | Photos, gradients |
| PNG | Lossless | Perfect | Universal | Screenshots, text, transparency |
| WebP | Lossy + lossless | Better than JPEG at same size | 97%+ browsers | General purpose replacement |
| AVIF | Lossy + lossless | Best compression/quality ratio | 93%+ browsers | Photos, high-quality images |
| JPEG XL | Lossy + lossless | Excellent | Limited (no Chrome/Edge) | Not recommended for web yet |
WebP: The Safe Default
WebP typically produces files 25-35% smaller than equivalent-quality JPEG. It supports both lossy and lossless compression, transparency (unlike JPEG), and animation (replacing GIF). With browser support above 97%, WebP is the safe default choice for most web images.
AVIF: The Best Compression
AVIF (based on the AV1 video codec) delivers stunning compression -- often 50% smaller than JPEG at equivalent quality, and 20% smaller than WebP. The trade-off is encoding speed: AVIF encoding is significantly slower than WebP or JPEG, which matters in build pipelines.
Browser support is at 93%+ (Chrome, Firefox, Safari 16+, Edge). The remaining gap is older Safari versions and some mobile browsers. For most sites, serving AVIF with a JPEG or WebP fallback covers all users.
JPEG XL: Not Yet
JPEG XL is technically superior to all the above -- it supports lossless transcoding from existing JPEG files (preserving exact quality while reducing size), progressive decoding, and excellent compression. However, Chrome and Edge dropped JPEG XL support in 2023, and without Chromium support, it is not viable for the web. Keep an eye on it, but do not invest in it today.
Optimization Tools
Sharp: The Build Pipeline Workhorse
Sharp is a Node.js image processing library built on libvips. It is fast (typically 4-5x faster than ImageMagick), handles all modern formats, and is the right choice for automated image processing in build pipelines and server-side applications.
npm install sharp
import sharp from "sharp";
// Basic optimization: resize and convert to WebP
await sharp("input.jpg")
.resize(1200, 800, { fit: "inside", withoutEnlargement: true })
.webp({ quality: 80 })
.toFile("output.webp");
// Generate multiple formats for <picture> element
async function optimizeImage(input: string, outputDir: string) {
const image = sharp(input);
const metadata = await image.metadata();
const baseName = path.basename(input, path.extname(input));
// AVIF (best compression, slowest to encode)
await image
.clone()
.resize(1200, null, { withoutEnlargement: true })
.avif({ quality: 65, effort: 4 })
.toFile(`${outputDir}/${baseName}.avif`);
// WebP (good compression, fast to encode)
await image
.clone()
.resize(1200, null, { withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(`${outputDir}/${baseName}.webp`);
// JPEG fallback
await image
.clone()
.resize(1200, null, { withoutEnlargement: true })
.jpeg({ quality: 85, mozjpeg: true })
.toFile(`${outputDir}/${baseName}.jpg`);
}
// Generate responsive image set
async function generateResponsiveSet(input: string, outputDir: string) {
const widths = [320, 640, 960, 1280, 1920];
const image = sharp(input);
const metadata = await image.metadata();
for (const width of widths) {
if (metadata.width && width > metadata.width) continue;
await image
.clone()
.resize(width, null, { withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(`${outputDir}/${path.basename(input, path.extname(input))}-${width}w.webp`);
}
}
Strengths: Fast (libvips is one of the fastest image libraries), comprehensive format support, extensive API (resize, crop, composite, color manipulation), streaming support, excellent for build pipelines and server-side processing.
Weaknesses: Node.js only, native dependency (prebuilt binaries for most platforms, but can cause issues on exotic architectures), API has a learning curve for complex operations.
Squoosh: Browser-Based and CLI
Squoosh (from the Chrome team) is a web-based image optimization tool at squoosh.app. It lets you compare formats and quality settings side-by-side with a visual diff slider. The CLI version can be used in build pipelines.
# Install CLI
npm install @squoosh/cli
# Optimize a single image
npx squoosh-cli --webp '{"quality": 80}' input.jpg
# Batch process a directory
npx squoosh-cli --webp '{"quality": 80}' --avif '{"quality": 50}' images/*.jpg
# Resize and convert
npx squoosh-cli --resize '{"width": 1200}' --webp '{"quality": 80}' input.jpg
Note: The Squoosh CLI has been deprecated by Google and is no longer actively maintained. The web app at squoosh.app still works and is useful for one-off comparisons, but for build pipeline automation, use Sharp instead.
Strengths: Web app is excellent for visual quality comparison, WASM-based (runs in the browser), supports every modern format.
Weaknesses: CLI is deprecated and unmaintained, slower than Sharp for batch processing, not suitable for production build pipelines going forward.
ImageOptim: Desktop Optimization
ImageOptim (macOS) is a drag-and-drop tool that optimizes images in place. It runs multiple optimization tools under the hood (MozJPEG, pngquant, Gifsicle, SVGO) and picks the best result.
For Linux, there are equivalent command-line tools:
# MozJPEG (better JPEG compression than libjpeg)
cjpeg -quality 85 input.jpg > output.jpg
# pngquant (lossy PNG compression -- huge size reductions)
pngquant --quality=65-80 --strip input.png -o output.png
# SVGO (SVG optimization)
npx svgo input.svg -o output.svg
# oxipng (lossless PNG optimization, written in Rust)
oxipng -o 4 --strip safe input.png
Best for: One-off optimization of images before committing to version control. Not a replacement for build pipeline automation, but useful as a pre-commit step.
Cloudinary: Optimization as a Service
Cloudinary (and similar services like Imgix and Bunny Optimizer) handle image optimization at the CDN level. You upload an original, and the CDN serves optimized versions based on the requesting browser, device, and connection speed.
<!-- Cloudinary URL-based transformations -->
<img src="https://res.cloudinary.com/myaccount/image/upload/w_800,q_auto,f_auto/my-image.jpg" />
The f_auto parameter serves AVIF to browsers that support it, WebP to those that do not, and JPEG as a final fallback. The q_auto parameter adjusts quality based on image content -- photos get higher compression than screenshots with text.
Strengths: Zero build pipeline changes, automatic format negotiation, on-the-fly resizing and cropping, CDN delivery, responsive image URLs.
Weaknesses: Vendor lock-in (image URLs are Cloudinary-specific), costs scale with transformations and bandwidth, adds a dependency on an external service, free tier is limited (25K transformations/month).
Build Pipeline Integration
Vite Plugin
// vite.config.ts
import { defineConfig } from "vite";
import { imagetools } from "vite-imagetools";
export default defineConfig({
plugins: [
imagetools({
defaultDirectives: (url) => {
if (url.searchParams.has("hero")) {
return new URLSearchParams({
format: "avif;webp;jpg",
width: "640;960;1280;1920",
quality: "80",
as: "picture",
});
}
return new URLSearchParams();
},
}),
],
});
// Usage in your app
import heroImage from "./hero.jpg?hero";
// Returns a <picture> element with multiple sources
Custom Build Script
For frameworks without a dedicated plugin, a build script using Sharp handles most needs:
// scripts/optimize-images.ts
import sharp from "sharp";
import { glob } from "glob";
import path from "path";
import fs from "fs/promises";
const INPUT_DIR = "src/assets/images";
const OUTPUT_DIR = "dist/images";
const WIDTHS = [320, 640, 960, 1280, 1920];
const FORMATS = ["avif", "webp", "jpg"] as const;
async function optimizeAll() {
const files = await glob(`${INPUT_DIR}/**/*.{jpg,jpeg,png}`);
await fs.mkdir(OUTPUT_DIR, { recursive: true });
for (const file of files) {
const baseName = path.basename(file, path.extname(file));
const image = sharp(file);
const metadata = await image.metadata();
for (const width of WIDTHS) {
if (metadata.width && width > metadata.width) continue;
for (const format of FORMATS) {
const output = `${OUTPUT_DIR}/${baseName}-${width}w.${format}`;
let pipeline = image.clone().resize(width);
switch (format) {
case "avif":
pipeline = pipeline.avif({ quality: 65, effort: 4 });
break;
case "webp":
pipeline = pipeline.webp({ quality: 80 });
break;
case "jpg":
pipeline = pipeline.jpeg({ quality: 85, mozjpeg: true });
break;
}
await pipeline.toFile(output);
}
}
console.log(`Optimized: ${file}`);
}
}
optimizeAll();
// package.json
{
"scripts": {
"images": "tsx scripts/optimize-images.ts",
"build": "npm run images && vite build"
}
}
Responsive Images in HTML
All that optimization work is wasted if you serve a 1920px image to a 375px phone screen. Use the <picture> element and srcset to serve the right image:
<!-- Full responsive image with format fallbacks -->
<picture>
<!-- AVIF (best compression) -->
<source
type="image/avif"
srcset="
hero-320w.avif 320w,
hero-640w.avif 640w,
hero-960w.avif 960w,
hero-1280w.avif 1280w,
hero-1920w.avif 1920w
"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 1200px"
/>
<!-- WebP fallback -->
<source
type="image/webp"
srcset="
hero-320w.webp 320w,
hero-640w.webp 640w,
hero-960w.webp 960w,
hero-1280w.webp 1280w,
hero-1920w.webp 1920w
"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 1200px"
/>
<!-- JPEG fallback for ancient browsers -->
<img
src="hero-1280w.jpg"
srcset="
hero-320w.jpg 320w,
hero-640w.jpg 640w,
hero-960w.jpg 960w,
hero-1280w.jpg 1280w,
hero-1920w.jpg 1920w
"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 1200px"
alt="Hero image description"
width="1920"
height="1080"
loading="lazy"
decoding="async"
/>
</picture>
Key attributes:
sizes: Tells the browser how wide the image will be at different viewport widths, so it can pick the right file fromsrcsetbefore layout is completeloading="lazy": Defers loading until the image is near the viewport (do not use for above-the-fold images)decoding="async": Allows the browser to decode the image off the main threadwidthandheight: Prevents layout shift by reserving space before the image loads
Quality Settings That Actually Make Sense
The quality slider is where most people either waste bytes or destroy quality. Here are sensible defaults:
| Format | Photos | Screenshots/UI | Illustrations |
|---|---|---|---|
| JPEG (MozJPEG) | 80-85 | 90-95 | 85-90 |
| WebP | 75-82 | 85-90 | 80-85 |
| AVIF | 60-70 | 75-85 | 65-75 |
AVIF quality numbers look low compared to JPEG, but the perceptual quality is equivalent or better at those settings. AVIF quality 65 looks comparable to JPEG quality 85.
Recommendations
Default format strategy: Serve AVIF with WebP fallback. The
<picture>element handles this cleanly. If you only have time for one format, choose WebP -- it has near-universal support and is meaningfully better than JPEG.Build pipeline: Use Sharp for automated optimization. It is the fastest Node.js option, actively maintained, and handles all formats. Write a build script that generates responsive variants and multiple formats from source images.
Responsive images: Always generate multiple widths (320, 640, 960, 1280, 1920 is a reasonable set). A 1920px image sent to a phone wastes 80% of the bytes. The
srcsetandsizesattributes are well-supported and make a massive difference.SVGs: Run SVGO on all SVG files. It removes editor metadata, unused elements, and redundant attributes. Size reductions of 30-60% are typical with no visual change.
Lazy loading: Add
loading="lazy"to every image except the first one visible on page load (the LCP candidate). For the LCP image, usefetchpriority="high"to tell the browser to prioritize it.Do not use Cloudinary unless you need it: Build-time optimization with Sharp is free and gives you full control. Cloudinary makes sense when you have user-uploaded images that need on-the-fly processing, or when you want to avoid maintaining image optimization infrastructure. For static site images, it is unnecessary overhead and cost.