Alexander Garcia
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.
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! ๐
copy in RsbuildLet'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"), }, }, });
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:
dist/static/image/tiki-social-project.3e0b4ad6.webpsrc becomes /static/image/tiki-social-project.3e0b4ad6.webpBut 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)Bundle size reduced by 58.8% after removing duplicate image copies
# 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 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"), }, }, });
Files to Copy (not imported):
_redirects - Netlify/Vercel routing rulesrobots.txt - SEO crawling rulessitemap.xml - SEO sitemapFiles to Import (processed with hashes):
.jpg, .jpeg, .png, .webp, .svg)# 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!)
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.
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 }
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
Optimized JPEGs with mozjpeg compression (no WebP conversion)
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!
When to use optimized JPEG:
<picture> element needed)When to use WebP:
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.
| Stage | Total Size | Main Images | Duplicates | Key Issue |
|---|---|---|---|---|
| Initial (Part 3) | 8.5 MB | 3.5 MB | ~5 MB | Duplicate images |
| After duplicate fix | 3.5 MB | 3.5 MB | 0 MB | Fixed! โ |
| After JPEG optimization | 3.5 MB | 3.4 MB | 0 MB | Smaller JPEGs |
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.
Look for:
output: { copy: [{ from: "static" }], // โ Danger: Copies everything }
Ask yourself:
static/ imported in code?copy configcopy# Before du -sh dist/ # After fix npm run build du -sh dist/ # Calculate savings
// 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" />;
// 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
The complete image optimization script is available in this blog's GitHub repository.
Key features:
copy config for routes, configs, etc.du -sh dist/ to track bundle sizeFrom the entire optimization series (Parts 1-3 + this fix):
Total improvement: 84.6% reduction ๐
Related Posts: