Oversized images are the single biggest LCP killer on most sites. A 4MB hero photo displays at 800x600 because CSS scales it down — but the user still downloaded the full 4MB. Combined with mobile users on slow connections, this turns into a 5-second LCP and a poor Core Web Vitals score. The fix is straightforward: resize to display size, compress aggressively, deliver responsive variants. Done right, you cut image bytes 60-80% with no visible quality loss.
Goal: serve the smallest image that still looks crisp at the displayed size, accounting for device pixel ratio (DPR).
// Display size: 800x600 CSS pixels // Standard display (1x DPR): serve 800x600 // Retina display (2x DPR): serve 1600x1200 // Modern phones (3x DPR): serve 2400x1800 (overkill, 2x usually fine) // Rule of thumb: serve at 2x DPR for retina compatibility, // don't waste bytes serving 3x — barely visible difference
const sharp = require('sharp');
async function generateVariants(input, basename) {
const sizes = [400, 800, 1200, 1600, 2400];
for (const width of sizes) {
await sharp(input)
.resize({ width, withoutEnlargement: true })
.jpeg({ quality: 82, mozjpeg: true })
.toFile(`./public/img/${basename}-${width}.jpg`);
await sharp(input)
.resize({ width, withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(`./public/img/${basename}-${width}.webp`);
}
}
WordPress generates multiple sizes automatically — thumbnail, medium, large, full. Configure additional sizes in functions.php:
add_image_size('hero-small', 400);
add_image_size('hero-medium', 800);
add_image_size('hero-large', 1200);
add_image_size('hero-xl', 1600);
add_image_size('hero-2x', 2400);
Cloudinary, ImageKit, Bunny CDN resize on the fly via URL parameters:
<img src="https://cdn.example.com/hero.jpg?w=800&q=82" alt="..."> <!-- Returns 800px-wide JPEG at quality 82 -->
// Sharp
sharp(input).jpeg({ quality: 82, mozjpeg: true });
// mozjpeg encoder produces smaller files than libjpeg at same quality
// quality 82 visually indistinguishable from 100 for most photos
// quality 100 typically 40-60% larger for no visible benefit
# pngquant for lossy PNG-8 pngquant --quality=70-90 --strip image.png -o image-optimised.png # Typically 60-80% size reduction # Visible quality loss minimal for photos # Keeps transparency unlike JPEG conversion
// Removes EXIF, ICC profile, GPS data
// Saves bytes AND privacy (no GPS in publicly shared photos)
sharp(input).jpeg({ quality: 82 }).toFile(output);
// Sharp strips by default — verify with `exiftool output.jpg`
<img src="/img/hero-1200.jpg"
srcset="/img/hero-400.jpg 400w,
/img/hero-800.jpg 800w,
/img/hero-1200.jpg 1200w,
/img/hero-1600.jpg 1600w,
/img/hero-2400.jpg 2400w"
sizes="(max-width: 600px) 100vw,
(max-width: 1200px) 50vw,
1200px"
alt="Description"
width="1200" height="675"
loading="lazy">
The sizes attribute tells the browser what size the image will be displayed at, based on viewport. Browser then picks the smallest srcset variant that's still big enough.
(max-width: 600px) 100vw — on screens up to 600px, image is full viewport width(max-width: 1200px) 50vw — on screens up to 1200px, image is half viewport width1200px — default, on larger screens image is fixed 1200px wide<picture>
<source type="image/avif" srcset="/img/hero-400.avif 400w, /img/hero-800.avif 800w,
/img/hero-1200.avif 1200w, /img/hero-2400.avif 2400w"
sizes="(max-width: 800px) 100vw, 1200px">
<source type="image/webp" srcset="/img/hero-400.webp 400w, /img/hero-800.webp 800w,
/img/hero-1200.webp 1200w, /img/hero-2400.webp 2400w"
sizes="(max-width: 800px) 100vw, 1200px">
<img src="/img/hero-1200.jpg"
srcset="/img/hero-400.jpg 400w, /img/hero-800.jpg 800w,
/img/hero-1200.jpg 1200w, /img/hero-2400.jpg 2400w"
sizes="(max-width: 800px) 100vw, 1200px"
width="1200" height="675"
alt="Description"
loading="lazy">
</picture>
The LCP (Largest Contentful Paint) image — usually the hero — gets priority:
<img src="/img/hero-1200.jpg"
srcset="..."
sizes="..."
width="1200" height="675"
alt="..."
fetchpriority="high"
loading="eager">
fetchpriority="high" — browser prioritises this downloadloading="eager" — load immediately, don't lazy-load<img src="/img/gallery-item.jpg"
loading="lazy"
decoding="async"
width="400" height="300"
alt="...">
Browser only downloads when image is near viewport. Most images on a page should have loading="lazy" — except the LCP image and any image visible before scrolling.
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="..."
width={1200}
height={675}
priority // for LCP image
sizes="(max-width: 800px) 100vw, 1200px"
/>
// Generates AVIF, WebP, multiple sizes, sets srcset, lazy by default
// Astro
import { Image } from 'astro:assets';
<Image src={heroImage} alt="..." widths={[400, 800, 1200]} formats={['avif', 'webp', 'jpg']} />
Verify image-size findings are cleared and measure byte savings.
Run Image Audit →