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.

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: