Optimizing bundle size with Rsbuild & Rspack: Part 1

Learn how we reduced our React site's bundle size by 74% using Rsbuild and Rspack.

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 Problem

When I ran npm run build on my portfolio site, I was shocked to see the bundle analysis:

  • Total bundle size: 22.8 MB
  • Main JavaScript bundle: 1,141.5 kB (385.1 kB gzipped)
  • Largest image: 7.5 MB (a single JPG!)
  • Initial load time: Way too slow

For a portfolio site, this was unacceptable. Users expect fast, snappy experiences, especially on first load. I needed to optimize.

The Tech Stack

Before diving into the optimization journey, here's what I was working with:

  • Build tool: Rsbuild (Rspack-based)
  • Bundler: Rspack (Rust-powered webpack alternative)
  • Framework: React 19 with React Router 7
  • UI Libraries: Radix UI, Framer Motion
  • Code highlighting: react-syntax-highlighter

Why Rsbuild and Rspack? They're significantly faster than traditional webpack builds while maintaining compatibility with the webpack ecosystem. But speed isn't worth much if your bundles are bloated.

Phase 1: Understanding the Baseline

First, I needed to understand what was in my bundle. The initial build output showed:

File (web) Size Gzip dist/static/js/lib-react.d04bc74c.js 182.2 kB 57.7 kB dist/static/js/index.7c5592a4.js 335.5 kB 84.6 kB dist/static/js/885.0bd6d84e.js 1141.5 kB 385.1 kB # 😱 dist/tiki-social-project.jpg 7456.6 kB # 😱😱 dist/me-dark.jpeg 731.6 kB dist/me-light.jpeg 746.4 kB dist/card-bg.png 1361.1 kB Total: 22794.9 kB 21451.8 kB

The biggest culprits:

  1. 885.0bd6d84e.js at 1.14 MB - This turned out to be react-syntax-highlighter with ALL language definitions bundled
  2. Images - Multiple multi-megabyte images that weren't optimized
  3. No code splitting - Everything loaded upfront

Phase 2: Setting Up Code Splitting

Rsbuild makes code splitting straightforward with its performance configuration. Here's what I added to rsbuild.config.ts:

import { defineConfig } from "@rsbuild/core"; import { pluginReact } from "@rsbuild/plugin-react"; export default defineConfig({ plugins: [pluginReact()], performance: { chunkSplit: { strategy: "split-by-experience", }, }, tools: { rspack: { optimization: { minimize: true, splitChunks: { chunks: "all", cacheGroups: { // Separate vendor libraries vendor: { test: /[\\/]node_modules[\\/]/, name: "vendor", priority: 10, reuseExistingChunk: true, }, // Separate react-syntax-highlighter syntaxHighlighter: { test: /[\\/]node_modules[\\/]react-syntax-highlighter[\\/]/, name: "syntax-highlighter", priority: 20, reuseExistingChunk: true, }, // Separate large UI libraries radix: { test: /[\\/]node_modules[\\/]@radix-ui[\\/]/, name: "radix-ui", priority: 15, reuseExistingChunk: true, }, }, }, }, }, }, });

What Does This Do?

chunkSplit: { strategy: "split-by-experience" }

  • Rsbuild's smart splitting strategy that optimizes for user experience
  • Balances between too many small chunks and too few large chunks
  • Considers chunk size, shared dependencies, and loading performance

cacheGroups

  • vendor: Separates all node_modules into a dedicated vendor bundle
  • syntaxHighlighter: Isolates the large syntax highlighter library (higher priority)
  • radix: Separates Radix UI components

priority: Higher numbers load first. We prioritize syntax-highlighter (20) over radix (15) over general vendor code (10).

Phase 3: Lazy Loading the Syntax Highlighter

The 1.14 MB syntax highlighter bundle was the biggest issue. It included:

  • ALL Prism language definitions (JavaScript, Python, Go, Rust, etc.)
  • ALL themes
  • The entire rendering engine

But here's the thing: most users never see code blocks on my homepage. Why load all that JavaScript upfront?

Solution: React.lazy() + PrismAsyncLight

I refactored the markdown components to lazy-load the syntax highlighter:

import { lazy, Suspense } from "react"; import type { SyntaxHighlighterProps } from "react-syntax-highlighter"; // Lazy load syntax highlighter - only when code blocks are rendered // Using PrismAsyncLight which only loads language definitions on-demand const LazyCodeBlock = lazy(() => Promise.all([ import("react-syntax-highlighter/dist/esm/prism-async-light"), import("react-syntax-highlighter/dist/esm/styles/prism/night-owl"), ]).then(([{ default: SyntaxHighlighter }, { default: nightOwl }]) => ({ default: ({ children, language, ...props }: SyntaxHighlighterProps) => ( <SyntaxHighlighter {...props} PreTag="div" language={language} style={nightOwl} wrapLongLines className="max-w-[800px]" > {children} </SyntaxHighlighter> ), })) ); export const markdownComponents = { code: (props) => { const { children, className } = props; const match = /language-(\w+)/.exec(className || ""); return match ? ( <Suspense fallback={ <pre className="bg-[#011627] text-white p-4 rounded"> <code>{children}</code> </pre> } > <LazyCodeBlock language={match[1]}>{children}</LazyCodeBlock> </Suspense> ) : ( <code className="bg-muted px-1 py-0.5 rounded">{children}</code> ); }, };

Key Points:

  1. React.lazy(): Dynamically imports the component only when needed
  2. PrismAsyncLight: A lighter version that loads language grammars on-demand
  3. Specific imports: Only importing prism-async-light and night-owl theme
  4. Suspense fallback: Shows a styled <pre> while the highlighter loads

The Results (So Far)

After implementing code splitting and lazy loading:

Code Splitting Results

Lazy loading and PrismAsyncLight reduced bundle sizes dramatically

Main BundleSyntax HighlighterGzipped Main03006009001200
Main Bundle
+73.9%
Syntax Highlighter
+95.4%
Gzipped Main
+63.2%

Before:

dist/static/js/885.0bd6d84e.js    1141.5 kB    385.1 kB (gzip)
dist/static/js/index.7c5592a4.js   335.5 kB     84.6 kB (gzip)

After:

dist/static/js/vendor.58f458b1.js            2233.8 kB    739.5 kB (gzip)
dist/static/js/index.55102e72.js               87.8 kB     31.1 kB (gzip) ✅
dist/static/js/radix-ui.3a4251b3.js            69.2 kB     20.7 kB (gzip)
dist/static/js/syntax-highlighter.9ac28cc2.js  52.2 kB     11.2 kB (gzip) ✅

The vendor bundle looks larger (2.2 MB), but that's expected - it now contains React, React Router, Framer Motion, and other dependencies that were previously scattered. The key win is the main bundle that loads first.

What's Next?

We've made great progress, but there's more to do:

  • Route-based code splitting - Only load the code for the current route
  • Image optimization - Tackle those multi-megabyte images
  • Performance budgets - Set up monitoring to prevent regressions

In Part 2, I'll cover route-based code splitting with React Router, which reduced the initial bundle by another 50%.

Tools & Resources


Next: Part 2 - Route-Based Code Splitting

Full series: