Your hero image is 4.2 MB. Your LCP is 6.8 seconds. Google is burying you on page four, and every second of delay costs you roughly 7% in conversions. Here's how to fix it without rewriting your app.
This guide walks through the image optimization techniques that actually move the needle on Core Web Vitals: picking the right format, compressing without wrecking quality, serving responsive sizes, lazy-loading correctly, and automating the whole pipeline in CI.
## Why Images Are Usually the Bottleneck
A quick reality check before we touch any code. According to HTTP Archive's Web Almanac, images account for roughly half the total bytes of an average page load. JavaScript gets all the attention, but images are usually the heavier payload.
The fix isn't one trick. It's a stack of techniques that compound:
- **Format choice** — the biggest single win, often 50–80% size reduction.
- **Compression** — trims the fat without visible quality loss.
- **Responsive delivery** — stop sending a 2400px image to a 375px phone.
- **Lazy loading** — defer offscreen images so they don't block the initial paint.
- **Automation** — make it repeatable so it survives the next sprint.
Let's go through each.
## Step 1: Choose the Right Format
Image format is the file type that determines how pixels are stored and compressed. Three formats matter in 2024:
**WebP** — Google's format, supported by every modern browser. Typically 25–35% smaller than JPEG at equivalent quality. Safe default.
**AVIF** — the newer format, based on the AV1 codec. Often 50% smaller than JPEG, with better quality at low bitrates. Browser support is now solid (Chrome, Firefox, Safari 16.4+). The catch: encoding is slow, so it's best done at build time.
**JPEG/PNG** — keep JPEG for legacy fallback, keep PNG only when you genuinely need lossless transparency (logos, screenshots of text).
The practical move: generate AVIF and WebP, fall back to JPEG. Here's how with ``:
The browser picks the first format it understands. Old browsers get the JPEG. Everyone else gets the smaller file.
Notice the `width` and `height` attributes. Always set them. They tell the browser how much space to reserve, which prevents **layout shift** — the annoying jump when an image finally loads. Layout shift is measured by CLS (Cumulative Layout Shift), one of the three Core Web Vitals.
## Step 2: Compress Without Visible Quality Loss
Once you've picked a format, tune the compression. For most photos, a quality setting of 75–82 in WebP or AVIF is visually indistinguishable from the original — but the file is dramatically smaller.
If you're on the command line, `sharp` is the workhorse. Here's a Node script that converts a folder of images to both formats:
javascript import sharp from 'sharp'; import { readdir } from 'fs/promises'; import path from 'path';
const INPUT_DIR = './src/images'; const OUTPUT_DIR = './public/images';
const files = await readdir(INPUT_DIR);
for (const file of files) { const input = path.join(INPUT_DIR, file); const name = path.parse(file).name;
await sharp(input) .resize({ width: 1600, withoutEnlargement: true }) .avif({ quality: 65 }) .toFile(path.join(OUTPUT_DIR, `${name}.avif`));
await sharp(input) .resize({ width: 1600, withoutEnlargement: true }) .webp({ quality: 80 }) .toFile(path.join(OUTPUT_DIR, `${name}.webp`)); }
console.log(`Processed ${files.length} images.`);
Run it and compare a few outputs side by side. You'll usually find quality 65 AVIF looks identical to the original JPEG at a third of the size.
## Step 3: Serve Responsive Sizes with `srcset`
A 1600px image on a 375px phone wastes bandwidth and CPU. The phone has to decode and downscale pixels it will never display. Use `srcset` and `sizes` to let the browser pick the right file.
The `w` descriptor tells the browser each file's intrinsic width. The `sizes` attribute describes how wide the image will render at various viewports. The browser combines these with the device pixel ratio and picks the smallest sufficient file.
The payoff is real. On a mobile connection, this alone can cut image bytes by 60–70%.
## Step 4: Lazy-Load Correctly (and Not Too Much)
Lazy loading means deferring image requests until the image is about to enter the viewport. The native `loading="lazy"` attribute does this with zero JavaScript.
But here's the trap: **never lazy-load your LCP image**. The LCP (Largest Contentful Paint) element is usually your hero image, and it's the most important thing to load fast. Lazy-loading it delays the very metric you're trying to improve.
The rule:
- Hero / above-the-fold images: `loading="eager"` and add `fetchpriority="high"`.
- Everything below the fold: `loading="lazy"`.
Also add `decoding="async"` to offscreen images so the browser can decode them off the main thread.
## Step 5: Automate It in CI
Manual optimization rots. Someone uploads a 5 MB PNG, and six months later you're back to square one. Put the pipeline in CI so every pull request with a new image gets optimized automatically.
A minimal GitHub Actions step:
yaml
- name: Optimize images
run: | npm install sharp node scripts/optimize-images.js
- name: Commit optimized assets
run: | git config user.name "ci-bot" git add public/images git commit -m "chore: optimize images" || echo "No changes" git push
This catches oversized images before they hit production. It's the difference between a one-time cleanup and a durable fix.
## Step 6: Don't Forget the Delivery Layer
Compression and formats only get you so far. The server serving those files matters too. If your images are on a slow shared host, TTFB (Time to First Byte) eats your gains.
Two things help here:
1. **A CDN** in front of your static assets, so images are cached close to users. 2. **An origin server with fast disk I/O**, especially if you're generating images on the fly.
For the origin, I've had good results with [PowerVPS](https://powervps.net/?from=32) for VPS hosting — solid disk performance for image-heavy workloads, and the pricing is reasonable for a staging-to-production setup. If you're serving a global audience and want something closer to edge locations, [Immers Cloud](https://en.immers.cloud/signup/r/20241007-8310688-334/) is worth a look for cloud instances with good network throughput.
If you're still deciding on infrastructure, [Server Rental Guide](https://serverrental.store) has a useful breakdown of hosting options by workload type — handy when you're matching server specs to your traffic profile.
## Measuring the Impact
Don't guess. Measure. Run Lighthouse before and after, and check these numbers:
- **LCP** — should drop below 2.5s.
- **CLS** — should stay under 0.1.
- **Total bytes** — watch the network panel; you'll often see 50%+ reductions.
If you can, test on a throttled "Slow 4G" connection. That's where the wins show up most dramatically.
## Conclusion
Image optimization isn't a single fix — it's a pipeline. Convert to AVIF/WebP with a JPEG fallback. Compress at quality 65–80. Serve responsive sizes with `srcset`. Lazy-load below-the-fold images only. Automate the whole thing in CI so it doesn't regress.
Start with format conversion. That's the biggest single win and takes an afternoon. Then layer in responsive delivery and lazy loading. Your LCP will thank you, and so will your users on slow connections.
Ship it, measure it, and keep the pipeline running.




Top comments (0)