Route-Level Code Splitting With React.lazy
Our initial bundle was 2.1MB. Splitting routes via React.lazy plus Suspense dropped it to 340KB on first paint. Three accordions on how I wire it.
By @meinakamura
January 24, 2026
·
Updated May 18, 2026
835 views
4
4.5 (10)
// The minimum viable split. React.lazy takes a function that returns a
// Promise resolving to a module with a default export. Wrapping the lazy
// component in <Suspense fallback={...}> tells React what to show while the
// chunk is in-flight. In a real bundler, the dynamic import() becomes its own
// chunk; here we mock the Promise so the snippet still runs in the playground.
const { useState, useEffect } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useEffect: (fn) => { const c = fn(); return typeof c === 'function' ? c : undefined; },
});
// Tiny React.lazy stand-in. Real React.lazy returns a special component type;
// here we just return a function that suspends until the import resolves.
function lazy(loader) {
let status = 'pending';
let result;
let promise;
return function LazyComponent(props) {
if (status === 'resolved') return result.default(props);
if (status === 'pending') {
if (!promise) {
promise = loader().then(
(mod) => { status = 'resolved'; result = mod; },
(err) => { status = 'rejected'; result = err; }
);
}
return { __suspended: true, promise };
}
throw result;
};
}
function Suspense({ fallback, children }) {
const node = typeof children === 'function' ? children() : children;
if (node && node.__suspended) {
return { type: 'fallback', value: fallback, await: node.promise };
}
return { type: 'rendered', value: node };
}
// Mock import() resolving to a module with a default-exported component.
const loadDashboard = () => Promise.resolve({
default: (props) => `<Dashboard userId=${props.userId} />`,
});
const Dashboard = lazy(loadDashboard);
// First render: the chunk is in flight, so we show the fallback.
const first = Suspense({ fallback: 'Loading...', children: () => Dashboard({ userId: 7 }) });
console.log('first render:', first.type, '->', first.value);
// Once the chunk resolves, a re-render returns the real tree.
first.await.then(() => {
const second = Suspense({ fallback: 'Loading...', children: () => Dashboard({ userId: 7 }) });
console.log('second render:', second.type, '->', second.value);
});The shape is what every router-driven split eventually settles on: const Dashboard = React.lazy(() => import('./Dashboard')) and a <Suspense fallback={<Spinner />}> boundary somewhere above the route. The dynamic import() is what tells webpack or Vite to split at that point, so ./Dashboard.js and everything it transitively imports become a separate chunk that the browser only fetches when the user navigates to that route. The default-export requirement is the most-asked gotcha: lazy expects mod.default, so a named export needs a wrapper module that re-exports it as default. The harness above is a sketch of what React does; the production version handles concurrent renders, error boundaries, and cache invalidation, but the call site is the same.
// The next decision after enabling lazy is where to put the Suspense boundary.
// One boundary at the app root means a single fallback for every navigation.
// One boundary per route lets each route show its own skeleton. Trade-offs:
// global is simpler, per-route is faster-feeling. Below: the same render tree
// modeled both ways so you can see the fallback granularity.
const { useState } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
});
// Simulated route tree. Each route is { path, component, fallback }.
const routes = [
{ path: '/', component: 'Home', fallback: 'home-skeleton' },
{ path: '/dashboard', component: 'Dashboard', fallback: 'dashboard-skeleton' },
{ path: '/settings', component: 'Settings', fallback: 'settings-skeleton' },
];
// Two strategies, same shape input.
function renderGlobalBoundary(currentPath, isLoaded) {
// <Suspense fallback={<Spinner />}> wraps the entire <Routes>.
if (!isLoaded) return { boundary: 'global', shows: 'generic-spinner' };
const route = routes.find((r) => r.path === currentPath);
return { boundary: 'global', shows: route.component };
}
function renderPerRouteBoundary(currentPath, isLoaded) {
// <Routes> with <Suspense fallback={route.fallback}> per <Route>.
const route = routes.find((r) => r.path === currentPath);
if (!route) return { boundary: 'per-route', shows: '404' };
if (!isLoaded) return { boundary: 'per-route', shows: route.fallback };
return { boundary: 'per-route', shows: route.component };
}
console.log('--- navigating to /dashboard ---');
console.log('global boundary, loading: ', renderGlobalBoundary('/dashboard', false));
console.log('per-route boundary, loading:', renderPerRouteBoundary('/dashboard', false));
console.log('global boundary, loaded: ', renderGlobalBoundary('/dashboard', true));
console.log('per-route boundary, loaded:', renderPerRouteBoundary('/dashboard', true));
console.log('--- navigating to /settings (different fallback) ---');
console.log('global boundary, loading: ', renderGlobalBoundary('/settings', false));
console.log('per-route boundary, loading:', renderPerRouteBoundary('/settings', false));
// The decision matrix as a plain table.
const decisions = [
{ criterion: 'shared layout chrome', global: 'kept on screen', perRoute: 'kept on screen' },
{ criterion: 'route-specific skeleton', global: 'no, generic spinner', perRoute: 'yes, matches the destination' },
{ criterion: 'first paint after slow link', global: 'spinner stays visible', perRoute: 'skeleton hints at structure' },
{ criterion: 'wiring cost', global: 'one Suspense', perRoute: 'one per route' },
{ criterion: 'used by', global: 'small apps', perRoute: 'product surfaces with distinct shapes' },
];
console.log('decision matrix:');
decisions.forEach((d) => console.log(' -', d.criterion + ':', 'global=' + d.global + ' | perRoute=' + d.perRoute));Both approaches work. A single boundary at the app shell is the right default for tools and dashboards where every screen looks roughly alike, because the fallback doubles as a loading state for the whole nav transition. Per-route boundaries pay off when each destination has its own visual shape: the user sees a dashboard skeleton on the way to a dashboard and a settings skeleton on the way to settings, which feels meaningfully faster even when the chunk size is identical. The hybrid I usually ship is a global boundary for cross-cutting transitions plus per-route boundaries for surfaces with heavy first-paint content. The matrix at the bottom is the cheat sheet I paste into PR descriptions when a teammate asks why a particular split was wrapped a certain way.
// The trick that actually moves perceived performance: start the chunk fetch
// when the user hovers a nav link, not when they click it. The dynamic import
// is idempotent (a webpack runtime caches the resolved module), so calling it
// in onMouseEnter is safe and the click then resolves instantly.
const { useEffect, useRef, useState, useCallback } = (typeof React !== 'undefined' ? React : {
useEffect: (fn) => { const c = fn(); return typeof c === 'function' ? c : undefined; },
useRef: (init) => ({ current: init }),
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useCallback: (f) => f,
});
// Network instrumentation so the playground can show the timing story.
let networkCalls = 0;
let now = 0;
function makeChunkLoader(chunkName, latencyMs) {
let cached = null;
return function load() {
if (cached) return cached; // dedup, the whole reason this trick works
networkCalls++;
const startedAt = now;
cached = new Promise((resolve) => {
now += latencyMs;
resolve({ default: () => chunkName + ' rendered', startedAt, finishedAt: now });
});
return cached;
};
}
const loadDashboard = makeChunkLoader('Dashboard', 600);
function NavLink({ to, onNavigate, hover, click }) {
const preloadRef = useRef(null);
function onMouseEnter() {
if (!preloadRef.current) preloadRef.current = loadDashboard();
hover && hover(now);
}
function onClick() {
// Reuses the in-flight or cached promise.
loadDashboard().then((mod) => onNavigate(mod.default()));
click && click(now);
}
return { onMouseEnter, onClick };
}
// Scenario A: cold click (no hover). The user pays the full chunk latency.
now = 0; networkCalls = 0;
const coldLoader = makeChunkLoader('Dashboard', 600);
(function coldClick() {
const start = now;
coldLoader().then((mod) => {
console.log('cold click -> rendered after', mod.finishedAt - start + 'ms', '| network calls:', networkCalls);
});
})();
// Scenario B: hover preloads, click 200ms later. The chunk is mostly already there.
now = 0; networkCalls = 0;
const warmLoader = makeChunkLoader('Dashboard', 600);
warmLoader(); // hover at t=0, request fires
const clickAt = 200;
now = clickAt;
warmLoader().then((mod) => {
console.log('warm click -> rendered after', mod.finishedAt - clickAt + 'ms', '| network calls:', networkCalls);
});The win comes from latency overlap. A typical chunk takes 200 to 800ms over a real network, and a typical hover-to-click delay is 100 to 400ms; if I start the fetch on hover, the click usually lands while the chunk is mid-flight or already cached. Webpack and Vite both deduplicate dynamic imports at the runtime level, so calling loadDashboard() in both onMouseEnter and onClick does not double-fetch. The downside is overhead on touch devices that fire mouseenter synthetically and on users who hover-scroll across a sidebar without intending to navigate; for those cases I gate the preload behind a 100ms timer or use IntersectionObserver to preload links only when they are in view. The numbers in the scenario output are the gap I usually close in production: 600ms cold turns into 400ms warm.
