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.

JavaScript
Frontend
3 snippets
react
performance-optimization
design-patterns
meinakamura

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.