Image optimization: From 7.5MB to 246Kb (Part 3)

The final part of our bundle optimization series. Learn how to reduce image sizes by up to 96% using WebP conversion, Sharp, and automated optimization scripts. Includes a complete Node.js image optimization tool you can use in your own projects.

Read time is about 11 minutes

Alexander Garcia is an effective JavaScript Engineer who crafts stunning web experiences.

Alexander Garcia is a meticulous Web Architect who creates scalable, maintainable web solutions.

Alexander Garcia is a passionate Software Consultant who develops extendable, fault-tolerant code.

Alexander Garcia is a detail-oriented Web Developer who builds user-friendly websites.

Alexander Garcia is a passionate Lead Software Engineer who builds user-friendly experiences.

Alexander Garcia is a trailblazing UI Engineer who develops pixel-perfect code and design.

The Image Problem

After optimizing our JavaScript bundles in Part 1 and Part 2, our total bundle size was still 8.5 MB. Why?

dist/tiki-social-project.jpg 7456.6 kB # ๐Ÿ˜ฑ 7.5 MB! dist/me-dark.jpeg 731.6 kB dist/me-light.jpeg 746.4 kB dist/card-bg.png 1361.1 kB dist/me-chair.png 244.0 kB dist/blog-head.png 252.6 kB

A single image was larger than all our JavaScript combined. Time to fix that.

Why Images Get So Large

Digital images are massive by default:

  • High resolution: 4096x2625px @ 72 DPI = 10.7 megapixels
  • Uncompressed formats: PNG stores every pixel's full color data
  • JPEG quality: Default quality settings (90-100) are overkill for web
  • Color depth: 24-bit color (16.7 million colors) when 8-bit would suffice

For a portfolio site, these are the wrong tradeoffs. We need:

  • โœ… Fast loading
  • โœ… Good visual quality
  • โœ… Responsive images
  • โŒ Not print-quality perfection

Strategy 1: WebP Conversion

WebP is a modern image format from Google that provides:

  • Better compression than JPEG/PNG (25-35% smaller)
  • Both lossy and lossless compression
  • Transparency support (like PNG)
  • Wide browser support (97%+ as of 2024)

Why Not Just Use WebP Everywhere?

We can, but it's good practice to provide fallbacks:

<picture> <source srcset="image.webp" type="image/webp" /> <img src="image.jpg" alt="Fallback for older browsers" /> </picture

However, with 97%+ support, I'm comfortable using WebP directly for my portfolio site.

Building an Automated Image Optimizer

Rather than manually converting images, I built a Node.js script using Sharp (a high-performance image processing library).

Installing Dependencies

npm install --save-dev sharp

The Optimization Script

I created scripts/optimize-images.mjs:

import sharp from "sharp"; import { readdir, stat } from "fs/promises"; import { join, extname } from "path"; const IMAGE_DIR = "./static"; const MAX_SIZE_KB = 500; // Compress images larger than 500 KB async function getImageFiles(dir) { const files = await readdir(dir); const imageFiles = []; for (const file of files) { const filePath = join(dir, file); const stats = await stat(filePath); if (stats.isFile()) { const ext = extname(file).toLowerCase(); if ([".jpg", ".jpeg", ".png"].includes(ext)) { imageFiles.push({ path: filePath, size: stats.size, name: file, }); } } } return imageFiles; } async function optimizeImage(imagePath) { const ext = extname(imagePath).toLowerCase(); const image = sharp(imagePath); const metadata = await image.metadata(); console.log(`Optimizing: ${imagePath}`); console.log( ` Original: ${metadata.width}x${metadata.height}, ${metadata.format}` ); if (ext === ".jpg" || ext === ".jpeg") { // Convert JPEG to WebP const webpPath = imagePath.replace(/\.jpe?g$/i, ".webp"); await image .resize({ width: Math.min(metadata.width, 1920), withoutEnlargement: true, }) .webp({ quality: 85 }) .toFile(webpPath); const webpStats = await stat(webpPath); console.log( ` WebP: ${webpPath} (${(webpStats.size / 1024).toFixed(2)} KB)` ); } else if (ext === ".png") { // Optimize PNG or convert to WebP for photos const optimizedPath = imagePath.replace(".png", ".optimized.png"); await image .resize({ width: Math.min(metadata.width, 1920), withoutEnlargement: true, }) .png({ quality: 85, compressionLevel: 9 }) .toFile(optimizedPath); const optimizedStats = await stat(optimizedPath); console.log( ` Optimized: ${optimizedPath} (${(optimizedStats.size / 1024).toFixed( 2 )} KB)` ); } } async function main() { console.log("Finding large images...\n"); const images = await getImageFiles(IMAGE_DIR); const largeImages = images.filter((img) => img.size / 1024 > MAX_SIZE_KB); if (largeImages.length === 0) { console.log("No large images found!"); return; } console.log(`Found ${largeImages.length} large images:\n`); largeImages.forEach((img) => { console.log(` ${img.name}: ${(img.size / 1024).toFixed(2)} KB`); }); console.log("\nOptimizing...\n"); for (const img of largeImages) { await optimizeImage(img.path); console.log(""); } console.log( "Done! Review the optimized files and replace the originals if satisfied." ); } main().catch(console.error);

Add to package.json

{ "scripts": { "optimize-images": "node scripts/optimize-images.mjs" } }

Running the Script

npm run optimize-images

Output:

Finding large images...

Found 4 large images:

  card-bg.png: 1329.23 KB
  me-dark.jpeg: 714.49 KB
  me-light.jpeg: 728.95 KB
  tiki-social-project.jpg: 7281.86 KB

Optimizing...

Optimizing: static/card-bg.png
  Original: 1000x1000, png
  Optimized: static/card-bg.optimized.png (275.75 KB)

...rest

Done!

The Results

Before โ†’ After Comparison

Image Optimization Results

WebP conversion and compression reduced image sizes by up to 96%

tiki-social-projectme-dark.jpegme-light.jpegcard-bg.png02000400060008000
tiki-social-project
+96.7%
me-dark.jpeg
+90.9%
me-light.jpeg
+91.1%
card-bg.png
+79.2%

Total image savings: ~9.4 MB

Updated Bundle Analysis

# After image optimization dist/static/image/tiki-social-project.webp 251.6 kB # Was 7.5 MB! dist/me-dark.webp 66.9 kB # Was 731 kB dist/me-light.webp 67.1 kB # Was 746 kB dist/card-bg.png 282.4 kB # Was 1.3 MB dist/me-chair.png 244.0 kB # Kept original dist/blog-head.png 252.6 kB # Kept original Total: 7506.4 kB # Was 17.3 MB!

Strategy 2: Using WebP in React

After generating WebP images, I updated my imports:

Before:

import tikiSocialProject from "@static/tiki-social-project.jpg"; <img src={tikiSocialProject} alt="Tiki Social Club" />;

After:

import tikiSocialProject from "@static/tiki-social-project.webp"; <img src={tikiSocialProject} alt="Tiki Social Club" loading="lazy" />;

With Picture Element (Progressive Enhancement):

For critical images, use <picture> for better fallback support:

import meLight from "@static/me-light.jpeg"; import meLightWebp from "@static/me-light.webp"; import meDark from "@static/me-dark.jpeg"; import meDarkWebp from "@static/me-dark.webp"; export function Hero() { const { theme } = useTheme(); return ( <picture> <source srcSet={theme === "light" ? meDarkWebp : meLightWebp} type="image/webp" /> <img src={theme === "light" ? meDark : meLight} alt="Alex Garcia" loading="lazy" /> </picture> ); }

What's happening:

  1. Browser checks if it supports WebP (type="image/webp")
  2. If yes โ†’ Load WebP (smaller, faster)
  3. If no โ†’ Fall back to JPEG (older browsers)

Strategy 3: Responsive Images

For larger images, serve different sizes for different screen widths:

import tikiSocialProject from "@static/tiki-social-project.webp"; <img srcSet={` ${tikiSocialProject}?w=400 400w, ${tikiSocialProject}?w=800 800w, ${tikiSocialProject}?w=1200 1200w `} sizes="(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px" src={tikiSocialProject} alt="Tiki Social Club" loading="lazy" />;

How it works:

  • Mobile (< 640px): Load 400px width version
  • Tablet (640-1024px): Load 800px width version
  • Desktop (> 1024px): Load 1200px width version

This requires a build-time image optimization plugin or a CDN like Cloudflare Images.

Trying (and Failing) with Rsbuild Plugin

I initially tried using @rsbuild/plugin-image-compress:

import { pluginImageCompress } from "@rsbuild/plugin-image-compress"; export default defineConfig({ plugins: [ pluginImageCompress({ jpeg: { quality: 85 }, png: { quality: 85 }, webp: { quality: 85 }, }), ], });

Result: Build errors with JPEG files. The plugin uses @squoosh/lib which had compatibility issues with my setup.

Lesson learned: Sometimes a custom script is more reliable than a plugin. The Node.js script gives you:

  • โœ… Full control over optimization settings
  • โœ… Preview before replacing originals
  • โœ… Easy to debug and customize
  • โœ… No build-time dependencies

Advanced: CDN Image Optimization

For production apps with user-uploaded images, consider a CDN:

Cloudflare Images

<img src="https://images.example.com/cdn-cgi/image/width=800,quality=85,format=webp/tiki-social.jpg" /

Imgix

<img src="https://example.imgix.net/tiki-social.jpg?w=800&q=85&auto=format" /

Next.js Image Component

import Image from "next/image"; <Image src="/tiki-social.jpg" width={800} height={600} alt="Tiki Social Club" />; // Automatically optimizes, lazy loads, and serves WebP

For static sites like mine, the build-time script approach works great.

Final Bundle Size Comparison

Journey Summary

Complete Optimization Journey

Total bundle size reduction from 22.8 MB to 7.5 MB (67% improvement)

InitialAfter code splittingAfter route splittingAfter image optimization06121824
Total Size
-67.1%
Main JS Bundle
-73.8%
Largest Image
-96.7%
Gzipped JS
-63.6%

Total Improvements

  • Bundle size: 22.8 MB โ†’ 7.5 MB (67.1% reduction ๐ŸŽ‰)
  • Main JS bundle: 335.5 kB โ†’ 87.8 kB (73.8% reduction ๐Ÿš€)
  • Largest image: 7.5 MB โ†’ 246 KB (96.7% reduction โšก)
  • Gzipped JS: 85.4 kB โ†’ 31.1 kB (63.6% reduction โœ…)

Performance Impact

Tested on Lighthouse (simulated slow 4G):

Performance Impact

Lighthouse metrics on simulated slow 4G - up to 74% faster load times

FCPLCPSpeed IndexPerformance Score0255075100
FCP
68%
LCP
74%
Speed Index
67%
Performance Score
+52%

Before Optimization

  • FCP (First Contentful Paint): 3.8s
  • LCP (Largest Contentful Paint): 8.2s
  • Speed Index: 5.4s
  • Performance Score: 62/100

After Optimization

  • FCP: 1.2s (68% faster โšก)
  • LCP: 2.1s (74% faster ๐Ÿš€)
  • Speed Index: 1.8s (67% faster โœ…)
  • Performance Score: 94/100 (+52% improvement ๐ŸŽ‰)

Best Practices Checklist

โœ… Convert JPEG/PNG to WebP for photos
โœ… Use PNG for graphics/logos with transparency
โœ… Resize images to max display size (1920px width for most screens)
โœ… Set loading="lazy" on below-the-fold images
โœ… Use <picture> for critical images with fallbacks
โœ… Compress images at 80-85 quality (sweet spot for web)
โœ… Use responsive images with srcset for different screen sizes
โœ… Automate optimization in your build process
โœ… Measure before/after with Lighthouse
โœ… Test on slow 3G/4G connections

Tools & Resources

Key Takeaways

  1. WebP is a game-changer - 25-35% smaller than JPEG/PNG with no visual loss
  2. Automate optimization - Build a script, don't manually convert images
  3. Resize to display size - A 4000px image on a 1920px screen is wasteful
  4. Lazy load below-the-fold - Don't load images users can't see yet
  5. Use Sharp - Fast, reliable, and works great for build-time optimization
  6. Measure the impact - Use Lighthouse to verify performance gains

Complete Optimization Recap

Over this 3-part series, we:

  1. Part 1: Set up code splitting, lazy loaded syntax highlighter
  2. Part 2: Implemented route-based lazy loading
  3. Part 3: Optimized images with WebP

Final result: A portfolio site that loads 68% faster with 67% less data.


Previous: Part 2 - Route-Based Code Splitting

Full series: