Alexander Garcia
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.
When I ran npm run build on my portfolio site, I was shocked to see the bundle analysis:
For a portfolio site, this was unacceptable. Users expect fast, snappy experiences, especially on first load. I needed to optimize.
Before diving into the optimization journey, here's what I was working with:
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.
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:
885.0bd6d84e.js at 1.14 MB - This turned out to be react-syntax-highlighter with ALL language definitions bundledRsbuild 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, }, }, }, }, }, }, });
chunkSplit: { strategy: "split-by-experience" }
cacheGroups
vendor: Separates all node_modules into a dedicated vendor bundlesyntaxHighlighter: Isolates the large syntax highlighter library (higher priority)radix: Separates Radix UI componentspriority: Higher numbers load first. We prioritize syntax-highlighter (20) over radix (15) over general vendor code (10).
The 1.14 MB syntax highlighter bundle was the biggest issue. It included:
But here's the thing: most users never see code blocks on my homepage. Why load all that JavaScript upfront?
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> ); }, };
React.lazy(): Dynamically imports the component only when neededPrismAsyncLight: A lighter version that loads language grammars on-demandprism-async-light and night-owl themeSuspense fallback: Shows a styled <pre> while the highlighter loadsAfter implementing code splitting and lazy loading:
Lazy loading and PrismAsyncLight reduced bundle sizes dramatically
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)
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.
We've made great progress, but there's more to do:
In Part 2, I'll cover route-based code splitting with React Router, which reduced the initial bundle by another 50%.
Next: Part 2 - Route-Based Code Splitting
Full series: