Skip to main content

Command Palette

Search for a command to run...

Code Splitting and Lazy Loading โ€” React.lazy, Suspense, and Dynamic Imports

Day 22 of 40 โ€” React System Design Series

Updated
โ€ข9 min read
Code Splitting and Lazy Loading โ€” React.lazy, Suspense, and Dynamic Imports
R

Hi, Iโ€™m Richa โ€” a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons Iโ€™ve learned working on large codebases. My goal is to share practical insights โ€” not shortcuts โ€” for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If youโ€™re serious about improving as an engineer, youโ€™re in the right place.


Hey everyone, Richa here! ๐Ÿ‘‹

loading gif

Day 22. And today we talk about one of the most impactful performance wins available in any React app โ€” and one of the most underused.

Code splitting.

Here's the thing about JavaScript bundles. When you build a React app, by default everything gets bundled into one (or a few) large JS files. The browser has to download, parse, and execute all of that before the user sees anything interactive.

Your settings page. Your admin dashboard. Your rarely-visited "about" page. All downloaded upfront. Even if the user is only visiting the homepage.

Code splitting lets you break that bundle into smaller chunks โ€” and load them only when they're needed.

loading gif

The problem โ€” one giant bundle

Without code splitting:

main.bundle.js  โ†’  2.4MB
                   โ”œโ”€โ”€ Login page
                   โ”œโ”€โ”€ Dashboard (complex, lots of charts)
                   โ”œโ”€โ”€ Settings
                   โ”œโ”€โ”€ Admin panel
                   โ”œโ”€โ”€ Reports page (rarely visited)
                   โ””โ”€โ”€ ... everything else

User visits the login page. Browser downloads 2.4MB of JavaScript. Most of it is code for pages they haven't visited yet and might never visit.

With code splitting:

main.bundle.js     โ†’  180KB  (shared code, router, auth)
login.chunk.js     โ†’  45KB   (loaded when user hits /login)
dashboard.chunk.js โ†’  380KB  (loaded when user navigates to /dashboard)
admin.chunk.js     โ†’  220KB  (loaded when admin navigates to /admin)
reports.chunk.js   โ†’  160KB  (loaded only if user visits /reports)

User visits the login page. Browser downloads 180KB + 45KB = 225KB. Everything else loads on demand as the user navigates.

loading gif


React.lazy โ€” the built-in solution

React.lazy lets you import a component dynamically. The component's code is loaded only when it's first rendered.

import { lazy, Suspense } from 'react';

// โŒ Eager import โ€” Dashboard code is in the main bundle regardless
import { DashboardPage } from './pages/DashboardPage';

// โœ… Lazy import โ€” Dashboard code loads only when this component is rendered
const DashboardPage = lazy(() => import('./pages/DashboardPage'));

React.lazy always works with Suspense. Suspense shows a fallback while the chunk is loading:

import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Spinner } from '@shared/components';

// Lazy import every page
const LoginPage      = lazy(() => import('./pages/LoginPage'));
const DashboardPage  = lazy(() => import('./pages/DashboardPage'));
const ProductsPage   = lazy(() => import('./pages/ProductsPage'));
const AdminPage      = lazy(() => import('./pages/AdminPage'));
const ReportsPage    = lazy(() => import('./pages/ReportsPage'));

export function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<Spinner fullPage />}>
        <Routes>
          <Route path="/login"      element={<LoginPage />} />
          <Route path="/dashboard"  element={<DashboardPage />} />
          <Route path="/products"   element={<ProductsPage />} />
          <Route path="/admin"      element={<AdminPage />} />
          <Route path="/reports"    element={<ReportsPage />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

loading gif

Now each page is its own chunk. Navigating to /dashboard for the first time triggers a network request for DashboardPage's chunk. Suspense shows the spinner while it loads โ€” then renders the page.

๐Ÿ’ก Route-level splitting is the highest-impact change you can make. Every page becomes its own chunk. Users only download code for pages they visit.

loading gif


Granular splitting โ€” lazy loading heavy components

Not just pages. Any heavy component that isn't needed on first render is a splitting candidate.

// A charting library โ€” very heavy, only needed on the reports page
const RevenueChart = lazy(() => import('./components/RevenueChart'));
const UserGrowthChart = lazy(() => import('./components/UserGrowthChart'));

function ReportsPage() {
  const [activeTab, setActiveTab] = useState<'revenue' | 'users'>('revenue');

  return (
    <div>
      <TabBar active={activeTab} onChange={setActiveTab} />

      <Suspense fallback={<ChartSkeleton />}>
        {activeTab === 'revenue' && <RevenueChart />}
        {activeTab === 'users' && <UserGrowthChart />}
      </Suspense>
    </div>
  );
}

The charting library (Recharts, Chart.js, etc.) can be 200โ€“400KB. If it's only on the reports page โ€” there's no reason it's in the main bundle.

loading gif


Named exports and lazy โ€” the gotcha

React.lazy only works with default exports. If your component uses a named export, you need a wrapper:

// โŒ Won't work โ€” lazy requires a default export
const { ProductList } = lazy(() => import('./components/ProductList'));

// โœ… Fix option 1 โ€” add default export to the file
// ProductList.tsx
export default function ProductList() { /* ... */ }

// โœ… Fix option 2 โ€” re-export as default in the lazy import
const ProductList = lazy(() =>
  import('./components/ProductList').then(module => ({
    default: module.ProductList,
  }))
);

The .then() pattern is useful when you can't modify the source file (third-party components, existing code).

loading gif


Suspense boundaries โ€” where to put them

Suspense can be nested. Use multiple boundaries to control what shows a spinner and what doesn't.

// One Suspense at the top โ€” entire page goes blank while chunk loads
function App() {
  return (
    <Suspense fallback={<FullPageSpinner />}>
      <Routes>...</Routes>
    </Suspense>
  );
}

loading gif

// Nested Suspense โ€” shell renders immediately, content streams in
function DashboardPage() {
  return (
    <DashboardShell>           {/* renders immediately โ€” no lazy */}
      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel />         {/* lazy โ€” loads its own chunk */}
      </Suspense>

      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />       {/* lazy โ€” loads separately */}
      </Suspense>

      <Suspense fallback={<TableSkeleton />}>
        <RecentOrdersTable />  {/* lazy โ€” loads separately */}
      </Suspense>
    </DashboardShell>
  );
}

With nested boundaries, the page shell appears instantly. Each section loads independently and replaces its skeleton when ready. Users see content progressively โ€” much better experience than waiting for everything at once.

loading gif


Preloading โ€” loading chunks before they're needed

The downside of lazy loading: there's a delay when the user first navigates to a lazy route. Small โ€” usually 100โ€“300ms โ€” but perceptible.

You can preload chunks before the user actually navigates โ€” triggered by hover or focus:

// Preload on hover โ€” chunk loads while user is moving their mouse
const DashboardPage = lazy(() => import('./pages/DashboardPage'));

// Create a preload function
function preloadDashboard() {
  import('./pages/DashboardPage'); // triggers the network request
}

function Navbar() {
  return (
    <nav>
      <Link
        to="/dashboard"
        onMouseEnter={preloadDashboard}  // preload on hover
        onFocus={preloadDashboard}       // preload on keyboard focus
      >
        Dashboard
      </Link>
    </nav>
  );
}

By the time the user clicks, the chunk is already loaded. Zero perceived delay.


Dynamic imports for non-component code

React.lazy is for components. But you can dynamically import anything โ€” libraries, utilities, large data.

// Only load the PDF generation library when the user clicks "Export"
async function handleExportPDF() {
  const { generatePDF } = await import('./utils/pdfGenerator');
  await generatePDF(reportData);
}

// Only load a large locale file when needed
async function loadLocale(locale: string) {
  const { messages } = await import(`./locales/${locale}.json`);
  return messages;
}

// Only load a heavy validation library when the form is submitted
async function handleFormSubmit(data: FormData) {
  const { validate } = await import('heavy-validation-library');
  const errors = await validate(data);
  if (errors.length > 0) return setErrors(errors);
  await submitForm(data);
}

This pattern is especially useful for:

  • Export features (PDF, Excel) โ€” heavy libraries only when export is triggered
  • Rich text editors โ€” load only when user clicks "edit"
  • Maps โ€” load the mapping library only when the map component is rendered
  • Large datasets โ€” load only when the user requests them

Measuring โ€” how to know if it's working

After adding code splitting, verify it's actually working:

In the browser:

  1. Open DevTools โ†’ Network tab
  2. Filter by JS
  3. Navigate to different routes
  4. Watch new chunk files download as you navigate

With Vite's bundle analyser:

npm install --save-dev rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    react(),
    visualizer({ open: true }), // opens a visual bundle map after build
  ],
});
npm run build

The visualiser shows you exactly what's in each chunk. If you see chart.js in your main bundle when it should only be on the reports page โ€” you have a missed split.


Common mistakes

Mistake 1 โ€” Splitting too granularly

// โŒ Splitting a tiny component โ€” chunk overhead costs more than it saves
const MySmallButton = lazy(() => import('./SmallButton')); // 2KB component

Network overhead for a chunk request is ~20ms minimum. For tiny components, the loading delay is worse than just including them in the main bundle. Split components that are > 30KB or that are heavy dependencies (chart libraries, editors, maps).

Mistake 2 โ€” No loading fallback

// โŒ No fallback โ€” user sees nothing while chunk loads
<Suspense>
  <HeavyComponent />
</Suspense>

// โœ… Always provide a meaningful fallback
<Suspense fallback={<Skeleton height={400} />}>
  <HeavyComponent />
</Suspense>

Mistake 3 โ€” Lazy importing inside a component

// โŒ This creates a new lazy component on every render โ€” loses the cache
function MyPage() {
  const Chart = lazy(() => import('./Chart')); // wrong โ€” inside component
  return <Chart />;
}

// โœ… Always define lazy imports at module level
const Chart = lazy(() => import('./Chart')); // correct โ€” outside component

function MyPage() {
  return <Chart />;
}

Today's takeaway

loading gif

Code splitting is one of the highest-ROI performance improvements you can make. Route-level splitting alone can cut your initial bundle by 60โ€“80%.

The pattern is simple:

  1. lazy() for the import
  2. Suspense with a meaningful fallback
  3. Preload on hover for zero perceived delay

Start with route-level splitting. Then identify heavy components (chart libraries, rich text editors, maps) and split those next. Use the bundle visualiser to find what's still in the main bundle that shouldn't be.

loading gif

See you tomorrow for Day 23 โ€” List virtualisation: rendering 10,000 rows without killing the browser.


โ† Day 21 โ€” Rendering Patterns โ†’ Day 23 โ€” List Virtualisation


Part of the React System Design Series โ€” 40 days, one topic per day.

React System Design โ€” Learning in Public

Part 22 of 34

I'm a React developer with 5.5 years of experience who spent 2 years in Lit.js โ€” and slowly forgot how React really works at a senior level. This series is my public journey back. Every day I cover one topic: state management, authentication, performance, system design interviews, testing โ€” the real stuff that separates a mid-level dev from a senior. Written honestly, with real code, for developers at every level.

Up next

List Virtualisation โ€” Rendering 10,000 Rows Without Killing the Browser

Day 23 of 40 โ€” React System Design Series