Image Optimization Strategies for Faster Websites
Practical image optimization from client projects—formats, responsive sizing, lazy loading, CDN delivery, and the mistakes that tank Core Web Vitals.
A UK e-commerce client came to me with a Lighthouse performance score of 34. Their agency had built a beautiful Next.js storefront. Product pages had hero images averaging 2.4MB each—full-resolution DSLR exports straight from the photographer’s Dropbox. On mobile 4G, the largest contentful paint hit 8.2 seconds. Bounce rate on product pages was 68%.
We didn’t redesign anything. We rebuilt the image pipeline: WebP and AVIF sources, responsive srcset, proper sizes attributes, lazy loading below the fold, and a CDN with automatic format negotiation. Lighthouse performance went to 91. LCP dropped to 1.9 seconds. The client’s conversion rate on mobile improved by 22% over six weeks—same traffic, same products, same design.
Images are the single largest performance lever on most websites I work on as a freelance frontend developer. Text compresses tiny. JavaScript can be code-split. Images routinely account for 50-70% of total page weight. This article covers the image optimization strategies I implement on React, Next.js, and Astro projects—and the mistakes that undo all your other performance work.
Why Images Dominate Performance Metrics
Core Web Vitals directly tie to images:
- LCP (Largest Contentful Paint) — usually a hero image, product photo, or large background
- CLS (Cumulative Layout Shift) — caused by images without explicit dimensions loading late
- INP (Interaction to Next Paint) — less image-related, but main-thread image decoding can contribute on image-heavy pages
Google uses Core Web Vitals as a ranking signal. Clients feel it in revenue—slow pages lose visitors before they see the CTA. Every optimization strategy below targets measurable metrics, not abstract “make it faster” goals.
Choose the Right Format
Format selection is the highest-impact decision. My decision tree:
| Format | Use case | Notes |
|---|---|---|
| AVIF | Photos, complex images | Best compression; 85-90% browser support with fallback |
| WebP | Photos, general raster | Universal modern support; good compression |
| SVG | Icons, logos, illustrations | Infinitely scalable; tiny file size for simple graphics |
| PNG | Screenshots needing transparency | Only when WebP/AVIF transparency isn’t suitable |
| JPEG | Legacy fallback | Last resort for photo fallbacks |
<picture>
<source srcset="/images/hero.avif" type="image/avif" />
<source srcset="/images/hero.webp" type="image/webp" />
<img
src="/images/hero.jpg"
alt="Product collection showcase"
width="1200"
height="800"
loading="eager"
fetchpriority="high"
/>
</picture>
On the e-commerce project, switching product images from JPEG to WebP with AVIF for supporting browsers cut average image payload from 2.4MB to 180KB per hero image—roughly 93% reduction with no visible quality loss at display size.
Mistake: using PNG for photographs. A 400KB PNG photo often becomes 40KB as WebP with identical visual quality at screen resolution.
Responsive Images with srcset and sizes
Serving a 2400px image to a 375px mobile screen wastes bandwidth and delays LCP. Responsive images deliver appropriately sized files:
<img
src="/images/product-800.webp"
srcset="
/images/product-400.webp 400w,
/images/product-800.webp 800w,
/images/product-1200.webp 1200w,
/images/product-1600.webp 1600w
"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 600px"
alt="Leather crossbody bag in cognac brown"
width="600"
height="750"
/>
The sizes attribute tells the browser how wide the image will display at each breakpoint, so it picks the optimal file from srcset. Getting sizes wrong is worse than not using srcset at all—the browser may download an oversized image thinking it needs full viewport width.
Next.js Image Component
On Next.js projects, the Image component handles srcset generation automatically:
import Image from "next/image";
import productImage from "@/assets/product-hero.jpg";
<Image
src={productImage}
alt="Leather crossbody bag in cognac brown"
width={600}
height={750}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 600px"
priority={isAboveFold}
className="rounded-lg object-cover"
/>
Configuration for modern formats:
// next.config.ts
const nextConfig = {
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
};
Astro Image Component
Astro’s built-in image service provides similar optimization:
---
import { Image } from "astro:assets";
import hero from "@/assets/hero.jpg";
---
<Image
src={hero}
alt="Team collaborating in modern office"
widths={[400, 800, 1200]}
formats={["avif", "webp"]}
sizes="(max-width: 768px) 100vw, 1200px"
loading="eager"
/>
Both frameworks generate optimized derivatives at build time. For CMS-sourced images, I use CDN transform URLs or a build-time download-and-optimize script.
Lazy Loading: When and How
Native lazy loading is free performance for below-the-fold images:
<img src="/gallery-3.webp" alt="Gallery image 3" loading="lazy" width="400" height="300" />
Rules I follow:
- Never lazy load LCP images. The hero, main product photo, or largest above-fold image needs
loading="eager"andfetchpriority="high". - Lazy load everything below the fold — gallery images, blog post inline images past the first screen, avatar lists, related products.
- Use intersection observer for background images — CSS
background-imagedoesn’t support native lazy loading.
"use client";
import { useEffect, useRef, useState } from "react";
export function LazyBackground({ src, className, children }: LazyBackgroundProps) {
const ref = useRef<HTMLDivElement>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setLoaded(true);
observer.disconnect();
}
},
{ rootMargin: "200px" }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={className}
style={loaded ? { backgroundImage: `url(${src})` } : undefined}
>
{children}
</div>
);
}
Mistake: lazy loading every image including the hero. Chrome’s lazy loading heuristic helps, but explicit priority on LCP images is essential. I’ve seen pages where the hero was lazy loaded and LCP measured a footer logo instead.
Preventing Layout Shift with Dimensions
CLS from images is entirely preventable. Every image needs explicit width and height attributes—or CSS aspect-ratio:
// Aspect ratio container prevents layout shift
<div className="relative aspect-[4/3] overflow-hidden rounded-lg">
<Image
src={src}
alt={alt}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 33vw"
/>
</div>
/* CSS-only approach */
.product-image {
aspect-ratio: 3 / 4;
width: 100%;
object-fit: cover;
}
On a US media site’s blog, fixing missing image dimensions on 200+ posts reduced CLS from 0.34 to 0.02—a green Core Web Vital score that had been failing for months.
CDN and Image Transformation Services
For image-heavy sites with CMS content, build-time optimization doesn’t scale. CDN image services transform on the fly:
Cloudinary:
https://res.cloudinary.com/demo/image/upload/w_800,f_auto,q_auto/sample.jpg
Imgix:
https://demo.imgix.net/product.jpg?w=800&auto=format,compress
Sanity image CDN:
urlFor(image).width(800).auto("format").quality(80).url();
f_auto or auto=format negotiates AVIF/WebP/JPEG based on browser support. q_auto applies perceptual quality optimization. These parameters alone often cut payload 60-80% without manual format conversion.
For the e-commerce client, we moved product images to Cloudinary with a upload preset that auto-generates responsive breakpoints. The Next.js loader prop connected directly:
// next.config.ts
images: {
loader: "custom",
loaderFile: "./src/lib/cloudinary-loader.ts",
},
// src/lib/cloudinary-loader.ts
export default function cloudinaryLoader({ src, width, quality }: ImageLoaderProps) {
const params = ["f_auto", "q_auto", `w_${width}`];
if (quality) params.push(`q_${quality}`);
return `https://res.cloudinary.com/${process.env.CLOUDINARY_CLOUD_NAME}/image/upload/${params.join(",")}/${src}`;
}
Art Direction with the Picture Element
Sometimes crop matters, not just size. The <picture> element serves different images at different breakpoints:
<picture>
<source
media="(max-width: 640px)"
srcset="/images/hero-mobile.webp"
width="640"
height="800"
/>
<source
media="(min-width: 641px)"
srcset="/images/hero-desktop.webp"
width="1440"
height="600"
/>
<img
src="/images/hero-desktop.jpg"
alt="Summer collection banner"
width="1440"
height="600"
fetchpriority="high"
/>
</picture>
Mobile gets a vertical crop showing the product. Desktop gets a wide banner. Same content, different composition—better than scaling a horizontal image into a tall mobile viewport with illegible detail.
SVG Optimization
SVGs for icons and logos need their own pipeline:
# SVGO reduces SVG file size 30-70%
npx svgo icons/ -o icons/optimized/
// Inline small SVGs for CSS styling control
import Logo from "@/assets/logo.svg?react";
<Logo className="h-8 w-auto text-brand-500" aria-label="Company name" />
Rules:
- Inline SVGs under 2KB for icons needing color control
- External SVG files for complex illustrations
- Always set
aria-labeloraria-hiddenon decorative SVGs - Never
<img src="icon.svg">when you need to style with CSScurrentColor
A client’s icon set was 48 individual PNG files totaling 340KB. After converting to optimized inline SVGs: 28KB total, crisp at every resolution, styleable with Tailwind text colors.
Build-Time Image Processing Pipeline
For static sites and local assets, I automate optimization in the build:
// scripts/optimize-images.ts
import sharp from "sharp";
import { glob } from "glob";
import path from "path";
const INPUT_DIR = "src/assets/images/raw";
const OUTPUT_DIR = "src/assets/images/optimized";
const WIDTHS = [400, 800, 1200, 1600];
async function optimizeImages() {
const files = await glob(`${INPUT_DIR}/**/*.{jpg,jpeg,png}`);
for (const file of files) {
const basename = path.basename(file, path.extname(file));
for (const width of WIDTHS) {
await sharp(file)
.resize(width, null, { withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(`${OUTPUT_DIR}/${basename}-${width}.webp`);
await sharp(file)
.resize(width, null, { withoutEnlargement: true })
.avif({ quality: 65 })
.toFile(`${OUTPUT_DIR}/${basename}-${width}.avif`);
}
}
}
optimizeImages();
Run as a prebuild step. Designers drop raw images in raw/; the pipeline outputs optimized derivatives. No manual export gymnastics.
Preloading Critical Images
LCP images benefit from preload hints in the document head:
---
// Astro layout head
---
<link
rel="preload"
as="image"
href="/images/hero-1200.webp"
imagesrcset="/images/hero-800.webp 800w, /images/hero-1200.webp 1200w"
imagesizes="100vw"
type="image/webp"
/>
// Next.js App Router with priority prop handles this automatically
<Image src={hero} priority alt="Hero" />
Preload only the LCP image—over-preloading competes with critical CSS and font resources. One preload per page maximum in most cases.
Real Project Scenario: Photography Portfolio
A EU photographer’s portfolio site used full-resolution images (6000x4000px, 8-15MB each) across 60 gallery pages. Stack: Astro. Goal: sub-2s LCP while maintaining print-quality downloads for potential buyers.
Strategy:
- Display images: AVIF/WebP at max 1600px width, quality 75
- Thumbnails: 400px for grid views, lazy loaded
- Download originals: separate “Download full resolution” link, not loaded on page view
- Blur placeholder: LQIP (Low Quality Image Placeholder) during load
---
import { Image } from "astro:assets";
import photo from "@/assets/gallery/sunset.jpg";
import placeholder from "@/assets/gallery/sunset-placeholder.jpg";
---
<Image
src={photo}
alt="Sunset over Scottish highlands, Glencoe valley"
widths={[400, 800, 1200, 1600]}
formats={["avif", "webp"]}
loading="lazy"
class="w-full"
/>
<a href="/downloads/sunset-full.jpg" download class="text-sm underline">
Download full resolution (12MB)
</a>
Results:
- Gallery page weight: 180MB → 2.1MB initial load
- LCP: 6.4s → 1.7s
- Full-res downloads available on demand for buyers
- Photographer reported faster client sharing and fewer “the site is slow” comments
Measuring Image Performance
Optimization without measurement is guessing. Tools I use per project:
| Tool | What it shows |
|---|---|
| Lighthouse | LCP element, total image weight, CLS |
| WebPageTest | Filmstrip, per-image download timeline |
| Chrome DevTools Network | Individual image sizes, format, cache status |
next/image or Astro build output | Generated derivative sizes |
# Quick audit: find oversized images in public folder
find public -name "*.jpg" -o -name "*.png" | xargs ls -lh | sort -k5 -h | tail -20
Set performance budgets in CI:
// lighthouserc.js
module.exports = {
ci: {
assert: {
assertions: {
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
"total-byte-weight": ["warn", { maxNumericValue: 500000 }],
},
},
},
};
Common Mistakes That Waste Your Work
Uploading display images at print resolution. 4000px images displayed at 400px waste 99% of the bytes.
Forgetting sizes with srcset. Browser defaults to 100vw and downloads the largest file.
Using CSS to scale oversized images. A 2000px image scaled to 300px with CSS still downloads at 2000px.
No caching headers on images. Immutable images should have long Cache-Control max-age. Next.js and Astro handle this for optimized images; raw public/ files often don’t.
Serving animated GIFs for demos. A 4MB GIF becomes a 200KB MP4 or WebM with better quality. Use <video autoplay muted loop playsinline> for animated content.
Ignoring alt text in performance context. Alt text doesn’t affect load speed, but empty or missing alt on LCP images affects accessibility audits that clients increasingly require.
Conclusion
Image optimization is the most reliable way to improve real-world website performance. Format selection, responsive delivery, lazy loading discipline, explicit dimensions, and CDN transformation—these strategies compound. The e-commerce client’s 22% mobile conversion improvement didn’t come from a redesign. It came from serving the right image, at the right size, in the right format, at the right time.
Every client project I start now includes an image pipeline decision in week one—not as a performance phase later, but as a foundation of how assets flow from design to production.
Your pages are only as fast as the largest image they load. Make that image intentional.
Key Takeaways
- Images typically account for 50-70% of page weight—optimize them first for Core Web Vitals gains
- Use AVIF with WebP fallback for photos; SVG for icons and logos; avoid PNG for photographs
- Implement responsive images with
srcsetand accuratesizes—wrongsizesdefeats the purpose - Never lazy load LCP images; use
priority/fetchpriority="high"on above-the-fold heroes - Set explicit
width/heightoraspect-ratioon every image to eliminate CLS - Use CDN transform URLs (
f_auto,q_auto) for CMS-sourced images at scale - Apply art direction with
<picture>when mobile and desktop need different crops - Automate build-time optimization with Sharp for local assets; SVGO for SVGs
- Preload only the single LCP image per page—don’t compete with critical resources
- Measure with Lighthouse and set CI budgets; audit
public/for oversized source files