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).

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: