Optimizing bundle size with Rsbuild & Rspack: Part 2

Part 2 of our bundle optimization series. Learn how implementing route-based lazy loading with React Router reduced our initial JavaScript bundle by an additional 50%, dramatically improving Time to First Byte (TTFB) and First Contentful Paint (FCP).

Read time is about 10 minutes

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.

Quick Recap

In Part 1, we reduced our main bundle from 335.5 kB to 87.8 kB using:

  • Code splitting with Rsbuild's split-by-experience strategy
  • Lazy loading react-syntax-highlighter
  • Vendor/library separation

But we still had a problem: every route's code was loaded upfront.

The Route-Loading Problem

Here's what my routes.tsx looked like:

import { createBrowserRouter } from "react-router"; import Homepage from "./pages/homepage"; import Projects from "./pages/projects"; import About from "./pages/about"; import Blog from "./pages/blog"; import BlogPost from "./pages/blog/post"; import CarbonReach from "./pages/projects/carbon-reach"; import Clarity from "./pages/projects/clarity"; import TikiSocial from "./pages/projects/tiki-social-club"; import GrafLaw from "./pages/projects/graf-law"; import HeroUI from "./pages/projects/rsbuild-heroui"; import Gremlin from "./pages/projects/gremlin"; import Vagov from "./pages/projects/vagov"; import Pricing from "./pages/pricing"; import Layout from "./components/common/layout"; export const router = createBrowserRouter([ { path: "/", Component: Layout, children: [ { index: true, Component: Homepage }, { path: "projects", children: [ { index: true, Component: Projects }, { path: "carbon-reach", Component: CarbonReach }, { path: "clarity", Component: Clarity }, // ... 5 more project routes ], }, { path: "about", Component: About }, { path: "blog", Component: Blog }, { path: "pricing", Component: Pricing }, ], }, ]);

The Problem

All those import statements at the top run immediately, meaning:

  • Homepage code loads ✅ (needed)
  • Projects page code loads ❌ (not needed yet)
  • About page code loads ❌ (not needed yet)
  • Blog page code loads ❌ (not needed yet)
  • 7 individual project pages load ❌❌❌ (definitely not needed!)

A user visiting just the homepage downloads code for every route. That's wasteful.

Why This Matters for TTFB

Time to First Byte (TTFB) measures how quickly the server responds. For client-side apps, it also includes:

  1. Download time - How long to download the JavaScript
  2. Parse time - How long the browser takes to parse/compile JavaScript
  3. Execution time - How long to run the initial JavaScript

Larger bundles mean:

  • ❌ Longer download (especially on slow connections)
  • ❌ Longer parse time (more code to compile)
  • ❌ Longer execution (more initialization code)
  • ❌ Delayed First Contentful Paint (FCP)

Solution: Route-Based Lazy Loading

React Router works beautifully with React's lazy() function. Here's the refactored approach:

import { createBrowserRouter } from "react-router"; import { lazy } from "react"; import { getAllBlogPosts } from "./lib/data"; // Eagerly load Layout and Homepage since they're needed immediately import Layout from "./components/common/layout"; import Homepage from "./pages/homepage"; // Lazy load all other routes for better code splitting const Projects = lazy(() => import("./pages/projects")); const About = lazy(() => import("./pages/about")); const Blog = lazy(() => import("./pages/blog")); const BlogPost = lazy(() => import("./pages/blog/post")); const Pricing = lazy(() => import("./pages/pricing")); // Lazy load project pages const CarbonReach = lazy(() => import("./pages/projects/carbon-reach")); const Clarity = lazy(() => import("./pages/projects/clarity")); // ... 5 more project routes export const router = createBrowserRouter([ { path: "/", Component: Layout, children: [ { index: true, Component: Homepage }, { path: "projects", children: [ { index: true, Component: Projects }, { path: "carbon-reach", Component: CarbonReach }, { path: "clarity", Component: Clarity }, // ... 5 more project routes ], }, { path: "about", Component: About }, { path: "blog", children: [ { index: true, Component: Blog, loader: getAllBlogPosts }, { path: ":post", Component: BlogPost }, ], }, { path: "pricing", Component: Pricing }, ], }, ]);

Key Changes:

  1. Keep critical routes eager: Layout and Homepage are still regular imports
  2. Lazy load everything else: Using lazy(() => import("./path"))
  3. Same route structure: No changes to the router configuration itself

Adding Loading States

When a user navigates to a lazy-loaded route, there's a brief moment while the chunk downloads. We need a fallback UI.

Here's the updated Layout component:

import React, { Suspense } from "react"; import { Outlet, ScrollRestoration } from "react-router"; import { Header } from "../header"; import { Footer } from "../footer"; function RouteLoader() { return ( <div className="min-h-screen flex items-center justify-center"> <div className="flex flex-col items-center gap-4"> <div className="w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" /> <p className="text-sm text-muted-foreground">Loading...</p> </div> </div> ); } export default function Layout() { return ( <> <Header /> <Suspense fallback={<RouteLoader />}> <Outlet /> </Suspense> <ScrollRestoration /> <Footer /> </> ); }

What's Happening:

  • <Suspense>: Wraps the <Outlet /> (where child routes render)
  • fallback={<RouteLoader />}: Shows a spinner while the route chunk loads
  • Seamless UX: Users see loading feedback instead of a blank screen

The Results

After implementing route-based code splitting, the build output showed dramatic improvements:

Bundle Size Breakdown

# Main bundles dist/static/js/index.55102e72.js 87.8 kB 31.1 kB (gzip) dist/static/js/vendor.58f458b1.js 2233.8 kB 739.5 kB (gzip) dist/static/js/radix-ui.3a4251b3.js 69.2 kB 20.7 kB (gzip) # Route chunks (loaded on-demand!) dist/static/js/async/851.js 5.5 kB 2.0 kB (gzip) dist/static/js/async/765.js 9.0 kB 2.9 kB (gzip) dist/static/js/async/227.js 9.3 kB 3.1 kB (gzip) dist/static/js/async/987.js 9.6 kB 2.7 kB (gzip) dist/static/js/async/60.js 13.5 kB 2.9 kB (gzip) dist/static/js/async/320.js 13.6 kB 3.0 kB (gzip) dist/static/js/async/140.js 17.2 kB 4.9 kB (gzip) dist/static/js/async/293.js 20.4 kB 4.2 kB (gzip) dist/static/js/async/198.js 22.2 kB 4.4 kB (gzip) dist/static/js/async/210.js 24.3 kB 4.4 kB (gzip) dist/static/js/async/341.js 27.4 kB 5.0 kB (gzip) dist/static/js/async/965.js 31.9 kB 6.0 kB (gzip) dist/static/js/async/637.js 73.6 kB 28.4 kB (gzip) # Lazy-loaded features dist/static/js/async/syntax-highlighter.js 52.2 kB 11.2 kB (gzip) Total: 8487.5 kB 6354.7 kB

Metrics Comparison

Bundle Optimization Results

Route-based lazy loading with React Router reduced bundle sizes dramatically

Total SizeMain BundleGzipped MainInitial Load JS085170255340
Total Size
+50.9%
Main Bundle
+73.9%
Gzipped Main
+63.6%
Initial Load JS
+14.3%

What Actually Loads

Homepage Visit:

  • index.js (87.8 kB)
  • vendor.js (2.2 MB - cached after first load)
  • radix-ui.js (69.2 kB)
  • ❌ Projects page (not loaded)
  • ❌ About page (not loaded)
  • ❌ Blog page (not loaded)

Navigate to /projects:

  • ✅ Already loaded: index, vendor, radix-ui
  • ✅ Download: async/293.js (~20 kB)
  • No re-download of shared code!

Navigate to /projects/tiki-social-club:

  • ✅ Already loaded: index, vendor, radix-ui, projects
  • ✅ Download: async/637.js (~73 kB)

Each route only downloads what it needs, when it needs it.

Performance Benefits

1. Faster Initial Load

  • Before: Download 337.3 kB → Parse → Execute → Render
  • After: Download 87.8 kB → Parse → Execute → Render
  • Result: Homepage renders ~200ms faster on 4G connections

2. Better Caching

Each route chunk has its own hash (293.9b5d6ab6.js). When you update:

  • The About page → Only async/140.js cache invalidates
  • The Projects page → Only async/293.js cache invalidates
  • Vendor libs stay cached → No re-download for users

3. Parallel Downloads

Modern browsers can download multiple route chunks in parallel when using:

<link rel="prefetch" href="/static/js/async/293.js" /

React Router can even prefetch routes when users hover over links!

Common Pitfalls to Avoid

1. Don't Lazy Load Everything

// ❌ Bad - Layout loads on every route const Layout = lazy(() => import("./components/common/layout")); // ✅ Good - Layout is always needed import Layout from "./components/common/layout";

2. Don't Forget Suspense

// ❌ Bad - No Suspense boundary <Outlet /> // ✅ Good - Suspense catches lazy components <Suspense fallback={<Loader />}> <Outlet /> </Suspense

3. Shared Components

If multiple routes use the same component:

// ❌ Bad - Duplicated in each route chunk const SharedComponent = lazy(() => import("./shared")); // ✅ Good - Extract to vendor or separate chunk import SharedComponent from "./shared";

Advanced: Route Prefetching

Want to make navigation instant? Prefetch routes when users hover:

import { Link } from "react-router"; function NavLink({ to, children }) { return ( <Link to={to} onMouseEnter={() => { // Prefetch route chunk on hover import(`./pages${to}`); }} > {children} </Link> ); }

When users hover over "Projects", the chunk starts downloading. By the time they click, it's already loaded!

What's Next?

We've tackled JavaScript bundles, but images are still massive:

  • tiki-social-project.jpg: 7.5 MB 😱
  • me-dark.jpeg: 731 kB
  • me-light.jpeg: 746 kB
  • card-bg.png: 1.3 MB

In Part 3, I'll show you how to optimize images using WebP, Sharp, and automated compression scripts, reducing image sizes by up to 96%.

Key Takeaways

✅ Route-based lazy loading cuts initial bundle size dramatically ✅ Use React.lazy() with Suspense for seamless UX ✅ Keep critical routes (Layout, Homepage) as regular imports ✅ Each route becomes its own chunk with independent caching ✅ Rspack handles code splitting automatically - no extra config needed


Next: Part 3 - Image Optimization

Full series: