Route-level Code Splitting: Shrinking Bundles in React
Master Code Splitting in React using dynamic imports and Suspense. Learn how to architect your app for faster initial loads and smaller bundle sizes.
Previously in this course, we discussed Establishing Performance Budgets to keep our application's footprint in check. While setting budgets prevents bloat, today we tackle the primary architectural strategy for staying within those limits: Route-level Code Splitting.
By default, bundlers like Webpack or Vite bundle your entire application into a single JavaScript file. As your app grows, this "main bundle" becomes a bottleneck, forcing users to download code for pages they haven't even visited yet. Route-level splitting allows us to break this monolith into smaller, lazily-loaded chunks.
The Mechanics of Dynamic Imports
At the heart of code splitting is the dynamic import() syntax. Unlike static import statements that are hoisted to the top of your file and included in the initial bundle, import() returns a promise that resolves to the module only when invoked.
In React, we use React.lazy to make this seamless. It lets you render a dynamic import as a regular component.
JAVASCRIPT// Instead of: import Dashboard from CE9178">'./Dashboard'; // We use: const Dashboard = React.lazy(() => import(CE9178">'./Dashboard'));
When React encounters this Dashboard component, it triggers the network request to fetch the corresponding chunk. Because this is asynchronous, we must wrap it in a Suspense boundary to provide a fallback UI while the chunk is in transit.
Configuring Route-Based Splitting
In our project, we are currently importing all pages in our App.js router. We need to refactor this to split our route definitions.
Before: Static Imports
JSXimport { Routes, Route } from CE9178">'react-router-dom'; import Home from CE9178">'./pages/Home'; import Dashboard from CE9178">'./pages/Dashboard'; import Settings from CE9178">'./pages/Settings'; function App() { return ( <Routes> <Route path="/" element={<Home />} /> <Route path="/dashboard" element={<Dashboard />} /> <Route path="/settings" element={<Settings />} /> </Routes> ); }
After: Dynamic Route Splitting
To optimize, we define our lazy components and wrap the Routes component in Suspense.
JSXimport React, { Suspense, lazy } from CE9178">'react'; import { Routes, Route } from CE9178">'react-router-dom'; const Home = lazy(() => import(CE9178">'./pages/Home')); const Dashboard = lazy(() => import(CE9178">'./pages/Dashboard')); const Settings = lazy(() => import(CE9178">'./pages/Settings')); function App() { return ( <Suspense fallback={<div>Loading page...</div>}> <Routes> <Route path="/" element={<Home />} /> <Route path="/dashboard" element={<Dashboard />} /> <Route path="/settings" element={<Settings />} /> </Routes> </Suspense> ); }
This simple change instructs your bundler to create separate files for Home, Dashboard, and Settings. The browser will now only download Dashboard.js when the user navigates to /dashboard.
Verifying Bundle Size Improvements
You cannot optimize what you cannot measure. After implementing these changes, you must verify the impact using your bundler's analysis tools.
| Tool | Purpose |
|---|---|
rollup-plugin-visualizer | Generates a treemap of your production bundle. |
webpack-bundle-analyzer | Visualizes the size of output files in Webpack. |
| Chrome DevTools (Network) | Inspects the waterfall of lazy-loaded chunks. |
When you run your build, look for the generated chunks in your dist/ or build/ folder. You should see separate files (e.g., Dashboard-xyz123.js) rather than one massive index.js.
Hands-on Exercise
- Open your project's main router file.
- Identify the three largest pages in your application.
- Convert their imports to
React.lazy. - Wrap your
Routesor the specificRouteelements inSuspensewith a loading spinner. - Run your build process and inspect the output directory to verify that the chunks are indeed separated.
Common Pitfalls
- Suspense Placement: Placing
Suspensetoo high can result in the entire layout "flashing" every time a tiny component loads. Place it as close to the lazy component as possible, or at the route level to maintain a good user experience. - Error Boundaries: If a network request fails (e.g., the user is offline or a new deployment invalidated the old chunk), the app will crash. Always wrap your lazy routes in an Error Boundary. We will cover advanced error handling in a future lesson, but for now, ensure your
Suspensestrategy doesn't mask these failures. - Over-splitting: Don't lazy-load every tiny button or icon. The overhead of individual HTTP requests can outweigh the benefits of a smaller bundle. Focus on route-level or heavy feature-level chunks.
Recap
By leveraging React.lazy and Suspense, we effectively decouple our application's entry point from its feature set. This reduces the initial payload, improves Time to Interactive (TTI), and aligns with our performance budgets by ensuring only necessary code is downloaded.
As we continue to build out our architecture, remember that modular directory structures make this splitting strategy significantly easier to maintain.
Up next: We will explore how to take expensive computations off the main thread using Web Workers, further ensuring our UI remains buttery smooth.
Work with me

Next.js Website & Landing Page Development
A blazing-fast, SEO-optimized website or landing page in Next.js โ the kind that loads instantly and ranks. Design-to-code, done right.

Headless WordPress + Next.js Frontend Development
Keep WordPress for content, get a lightning-fast Next.js frontend. The best of both worlds โ familiar editing, modern speed.