Alexander Garcia
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.
In Part 1, we reduced our main bundle from 335.5 kB to 87.8 kB using:
split-by-experience strategyBut we still had a problem: every route's code was loaded upfront.
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 }, ], }, ]);
All those import statements at the top run immediately, meaning:
A user visiting just the homepage downloads code for every route. That's wasteful.
Time to First Byte (TTFB) measures how quickly the server responds. For client-side apps, it also includes:
Larger bundles mean:
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 }, ], }, ]);
Layout and Homepage are still regular importslazy(() => import("./path"))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 /> </> ); }
<Suspense>: Wraps the <Outlet /> (where child routes render)fallback={<RouteLoader />}: Shows a spinner while the route chunk loadsAfter implementing route-based code splitting, the build output showed dramatic improvements:
# 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
Route-based lazy loading with React Router reduced bundle sizes dramatically
Homepage Visit:
index.js (87.8 kB)vendor.js (2.2 MB - cached after first load)radix-ui.js (69.2 kB)Navigate to /projects:
async/293.js (~20 kB)Navigate to /projects/tiki-social-club:
async/637.js (~73 kB)Each route only downloads what it needs, when it needs it.
Each route chunk has its own hash (293.9b5d6ab6.js). When you update:
async/140.js cache invalidatesasync/293.js cache invalidatesModern 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!
// ❌ Bad - Layout loads on every route const Layout = lazy(() => import("./components/common/layout")); // ✅ Good - Layout is always needed import Layout from "./components/common/layout";
// ❌ Bad - No Suspense boundary <Outlet /> // ✅ Good - Suspense catches lazy components <Suspense fallback={<Loader />}> <Outlet /> </Suspense
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";
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!
We've tackled JavaScript bundles, but images are still massive:
tiki-social-project.jpg: 7.5 MB 😱me-dark.jpeg: 731 kBme-light.jpeg: 746 kBcard-bg.png: 1.3 MBIn Part 3, I'll show you how to optimize images using WebP, Sharp, and automated compression scripts, reducing image sizes by up to 96%.
✅ 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: