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