I'm an idiot ๐Ÿ˜‘, or trimming the fat with JavaScript and Rsbuild

Discover how a simple misconfiguration in Rsbuild was duplicating every image in our bundle (Reduced further from 8.5MB to 3.5MB). Learn how to fix it and optimize JPEG images for the web without converting to WebP.

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 Discovery

After implementing all the optimizations from Part 1, Part 2, and Part 3, our bundle size was down to 8.5 MB. Great progress from the original 22.8 MB, right?

But something didn't add up. When I ran npm run build, I noticed something strange ๐Ÿ˜ฌ

dist/ tiki-social-project.webp 251.6 kB # Wait, this looks familiar... me-dark.webp 66.9 kB me-light.webp 67.1 kB blog-head.png 252.6 kB card-bg.png 282.4 kB static/image/ tiki-social-project.3e0b4ad6.webp 251.6 kB # Duplicate! me-dark.3c9199e8.webp 66.9 kB # Duplicate! me-light.7403bf19.webp 67.1 kB # Duplicate! blog-head.5f3c7cc9.png 252.6 kB # Duplicate!

We were shipping every image twice! ๐Ÿ˜‘

The Root Cause: Misconfigured copy in Rsbuild

Let's look at the problematic configuration in rsbuild.config.ts:

export default defineConfig({ output: { copy: [{ from: "static" }], // โŒ This copies EVERYTHING filenameHash: true, }, resolve: { alias: { "@static": path.resolve(__dirname, "./static"), }, }, });

What's Happening Under the Hood

When you import images in React:

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

Rsbuild (via Rspack) does the following:

  1. Processes the import โ†’ Optimizes the image
  2. Outputs with content hash โ†’ dist/static/image/tiki-social-project.3e0b4ad6.webp
  3. Updates the import โ†’ src becomes /static/image/tiki-social-project.3e0b4ad6.webp

But with copy: [{ from: "static" }], Rsbuild also copies the entire static/ folder to dist/, resulting in:

  • โœ… dist/static/image/tiki-social-project.3e0b4ad6.webp (processed, with hash)
  • โŒ dist/tiki-social-project.webp (unprocessed duplicate)

The Cost of Duplication

Impact of Duplicate Images

Bundle size reduced by 58.8% after removing duplicate image copies

Total Bundle SizeWasted SpaceNumber of Files015304560
Bundle Size Reduction
-58.8%
Space Saved
5 MB
Fewer Files
-40%
# With duplicates dist/tiki-social-project.webp 251.6 kB # Not used dist/me-dark.webp 66.9 kB # Not used dist/me-light.webp 67.1 kB # Not used dist/blog-head.png 252.6 kB # Not used dist/card-bg.png 282.4 kB # This wasn't even being imported dist/me-chair.png 244.0 kB # Not used # ... many more Total wasted space: ~5 MB

These duplicate files are never referenced in the built HTML/JS, but they still inflate your deployment size and CDN costs.

The Fix: Selective Copying

The solution is to only copy files that aren't imported in your code:

export default defineConfig({ output: { copy: [ // Only copy files that are NOT imported in code { from: "static/_redirects", to: "_redirects" }, // Favicon is referenced in HTML template, not imported // Other images are imported in code and will be processed with hashes ], filenameHash: true, }, resolve: { alias: { "@static": path.resolve(__dirname, "./static"), }, }, });

What Gets Copied vs. Processed

Files to Copy (not imported):

  • _redirects - Netlify/Vercel routing rules
  • robots.txt - SEO crawling rules
  • sitemap.xml - SEO sitemap

Files to Import (processed with hashes):

  • Images (.jpg, .jpeg, .png, .webp, .svg)
  • Fonts referenced in CSS
  • Any asset you want cache-busted with content hashes

The Result

# After fix dist/ _redirects # Copied (needed) favicon-128x128.png # Handled by html.favicon config static/image/ tiki-social-project.3e0b4ad6.webp 251.6 kB โœ… Only copy me-dark.3c9199e8.webp 66.9 kB โœ… Only copy blog-head.5f3c7cc9.png 252.6 kB โœ… Only copy Total bundle: 3.5 MB (down from 8.5 MB!)

Bonus: Optimizing JPEGs Without WebP Conversion

While fixing the duplicates, I realized some images work better as optimized JPEGs rather than WebP. Specifically, the me-dark.jpeg and me-light.jpeg images needed to maintain JPEG format for compatibility.

Adding JPEG-Only Optimization

I extended the optimize-images.mjs script to support JPEG optimization without WebP conversion:

async function optimizeJpegWithoutWebp(imagePath) { const ext = extname(imagePath).toLowerCase(); if (ext !== ".jpg" && ext !== ".jpeg") { console.log(`Skipping ${imagePath} - not a JPEG file`); return; } const image = sharp(imagePath); const metadata = await image.metadata(); console.log(`Optimizing JPEG (no WebP): ${imagePath}`); console.log( ` Original: ${metadata.width}x${metadata.height}, ${metadata.format}` ); // Optimize JPEG without converting to WebP const optimizedPath = imagePath.replace(/\.jpe?g$/i, ".optimized.jpg"); await image .resize({ width: Math.min(metadata.width, 1920), withoutEnlargement: true }) .jpeg({ quality: 85, mozjpeg: true }) .toFile(optimizedPath); const optimizedStats = await stat(optimizedPath); const originalStats = await stat(imagePath); const reduction = ( (1 - optimizedStats.size / originalStats.size) * 100 ).toFixed(1); console.log(` Original: ${(originalStats.size / 1024).toFixed(2)} KB`); console.log( ` Optimized: ${optimizedPath} (${(optimizedStats.size / 1024).toFixed( 2 )} KB)` ); console.log(` Reduction: ${reduction}%`); } async function optimizeSpecificJpegs() { const specificImages = ["me-dark.jpeg", "me-light.jpeg"]; console.log("Optimizing specific JPEG images without WebP conversion...\n"); for (const imageName of specificImages) { const imagePath = join(IMAGE_DIR, imageName); try { await stat(imagePath); await optimizeJpegWithoutWebp(imagePath); console.log(""); } catch (error) { console.log(` ${imageName} not found, skipping...`); } } console.log( "Done! Review the optimized files and replace the originals if satisfied." ); } async function main() { // Check if user wants to optimize specific JPEGs without WebP const args = process.argv.slice(2); const optimizeJpegOnly = args.includes("--jpeg-only") || args.includes("-j"); if (optimizeJpegOnly) { await optimizeSpecificJpegs(); return; } // ... existing code for WebP conversion }

Running the JPEG Optimizer

Add a new script to package.json:

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

Then run:

npm run optimize-images:jpeg

JPEG Optimization Results

JPEG Optimization Results

Optimized JPEGs with mozjpeg compression (no WebP conversion)

me-dark.jpegme-light.jpeg0200400600800
me-dark.jpeg
91.8%
714 kB โ†’ 59 kB
me-light.jpeg
91.9%
729 kB โ†’ 59 kB
Optimizing specific JPEG images without WebP conversion... Optimizing JPEG (no WebP): static/me-dark.jpeg Original: 648x1152, jpeg Original: 714.49 KB Optimized: static/me-dark.optimized.jpg (58.58 KB) Reduction: 91.8% Optimizing JPEG (no WebP): static/me-light.jpeg Original: 648x1152, jpeg Original: 728.95 KB Optimized: static/me-light.optimized.jpg (59.38 KB) Reduction: 91.9% Done!

Why JPEG vs WebP?

When to use optimized JPEG:

  • โœ… Better compatibility with older browsers/systems
  • โœ… Simpler fallback handling (no <picture> element needed)
  • โœ… Easier to integrate with email clients or external systems
  • โœ… Smaller bundle when you don't need transparency

When to use WebP:

  • โœ… Best compression (25-35% better than JPEG)
  • โœ… Supports transparency (like PNG)
  • โœ… Supported by 97%+ of browsers (as of 2024)
  • โœ… Better for photos and complex images

In my case, the profile images were used in various contexts where JPEG compatibility was more important than the extra 5-10% compression from WebP.

Complete Before/After Comparison

Bundle Size Journey

StageTotal SizeMain ImagesDuplicatesKey Issue
Initial (Part 3)8.5 MB3.5 MB~5 MBDuplicate images
After duplicate fix3.5 MB3.5 MB0 MBFixed! โœ…
After JPEG optimization3.5 MB3.4 MB0 MBSmaller JPEGs

Key Metrics

  • Bundle size reduction: 8.5 MB โ†’ 3.5 MB (58.8% smaller ๐ŸŽ‰)
  • Wasted duplicate space: ~5 MB removed
  • JPEG optimization: 714 KB โ†’ 59 KB per image (91.8% smaller)
  • Build output files: Reduced from 50+ to 30 (no duplicates)

How to Audit Your Own Project

1. Check for Duplicates

After building, run:

npm run build cd dist find . -type f -name "*.jpg" -o -name "*.png" -o -name "*.webp" | sort

If you see the same filename appear twice (once with hash, once without), you have duplicates.

2. Analyze Your Rsbuild Config

Look for:

output: { copy: [{ from: "static" }], // โŒ Danger: Copies everything }

Ask yourself:

  • Are images in static/ imported in code?
  • If yes, remove them from copy config
  • If no, they should stay in copy

3. Measure the Impact

# Before du -sh dist/ # After fix npm run build du -sh dist/ # Calculate savings

Best Practices for Rsbuild Asset Handling

โœ… Do This

// rsbuild.config.ts export default defineConfig({ output: { copy: [ // Only non-imported files { from: "static/_redirects", to: "_redirects" }, { from: "static/robots.txt", to: "robots.txt" }, ], }, resolve: { alias: { "@static": path.resolve(__dirname, "./static"), }, }, });
// In your React components - import images import profileImage from "@static/me-dark.webp"; <img src={profileImage} alt="Profile" />;

โŒ Don't Do This

// rsbuild.config.ts export default defineConfig({ output: { copy: [{ from: "static" }], // โŒ Copies everything, including imported images }, });
// In your React components - hardcoded paths <img src="/me-dark.webp" alt="Profile" /> // โŒ No cache busting, no optimization

Tools & Scripts

The complete image optimization script is available in this blog's GitHub repository.

Key features:

  • WebP conversion for maximum compression
  • JPEG-only optimization for compatibility
  • Automatic duplicate detection
  • Before/after size reporting

Key Takeaways

  1. โœ… Import images in code - Let Rsbuild process and hash them
  2. โœ… Only copy non-imported files - Use copy config for routes, configs, etc.
  3. โœ… Use Sharp for optimization - Fast, reliable, and highly configurable
  4. โœ… Choose the right format - WebP for most cases, JPEG for compatibility
  5. โœ… Audit your build output - Check for duplicates after every optimization
  6. โœ… Measure everything - Use du -sh dist/ to track bundle size

Results Summary

From the entire optimization series (Parts 1-3 + this fix):

  • Initial bundle: 22.8 MB
  • After JS optimizations (Parts 1-2): 8.5 MB
  • After image optimization (Part 3): 8.5 MB (but with duplicates)
  • After duplicate fix (this post): 3.5 MB โœ…

Total improvement: 84.6% reduction ๐Ÿš€


Related Posts: