Skip to main content
React Performance·Lesson 3 of 5

Code Splitting & Lazy Loading

Why Code Splitting Matters

A typical React app bundles all JavaScript into one or a few large files. Users download and parse everything upfront — even code for pages they may never visit. Code splitting breaks the bundle into smaller chunks loaded on demand.

Dynamic Imports

JavaScript's import() expression returns a promise that resolves to the module. Bundlers like Webpack and Turbopack automatically split these into separate chunks.

// Static import — included in the main bundle
import { heavyAnalytics } from "./analytics";

// Dynamic import — loaded only when called
async function trackEvent(event: string) {
  const { heavyAnalytics } = await import("./analytics");
  heavyAnalytics.track(event);
}

This is useful for code that isn't needed immediately — analytics libraries, rich text editors, charting libraries, etc.

React.lazy

React.lazy lets you defer loading a component's code until it is first rendered.

import { lazy, Suspense } from "react";

// The component's code is split into a separate chunk
const MarkdownEditor = lazy(() => import("./MarkdownEditor"));

function PostPage() {
  const [editing, setEditing] = useState(false);

  return (
    <div>
      <button onClick={()=> setEditing(true)}>Edit Post</button>
      {editing && (
        <Suspense fallback={<div>Loading editor...</div>}>
          <MarkdownEditor />
        </Suspense>
      )}
    </div>
  );
}

React.lazy requires a default export. If your component uses a named export, wrap the import:

const Chart = lazy(() =>
  import("./charts").then((mod) => ({ default: mod.BarChart }))
);

Suspense Boundaries

Suspense shows a fallback UI while lazy components (or data) are loading. Place boundaries strategically — too high and you get a full-page spinner; too low and you get layout jank.

function App() {
  return (
    <div className="layout">
      {/* Navigation loads instantly */}
      <Nav />

      {/* Page-level Suspense — shows skeleton for the whole content area */}
      <Suspense fallback={<PageSkeleton />}>
        <main>
          <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/dashboard" element={<Dashboard />} />
            <Route path="/settings" element={<Settings />} />
          </Routes>
        </main>
      </Suspense>
    </div>
  );
}

const Home = lazy(() => import("./pages/Home"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));

Nested Suspense Boundaries

You can nest boundaries for more granular loading states:

function Dashboard() {
  return (
    <div className="grid grid-cols-2 gap-4">
      <Suspense fallback={<CardSkeleton />}>
        <RevenueChart />
      </Suspense>
      <Suspense fallback={<CardSkeleton />}>
        <UserStats />
      </Suspense>
      <Suspense fallback={<CardSkeleton />}>
        <ActivityFeed />
      </Suspense>
    </div>
  );
}

Each card loads independently — the first one to resolve displays immediately while others still show skeletons.

Route-Based Splitting in Next.js

Next.js App Router automatically code-splits at the page level. Every page.tsx is its own chunk. You can further optimize with:

// app/dashboard/page.tsx
import { Suspense } from "react";
import dynamic from "next/dynamic";

// Heavy component loaded only on this route
const AnalyticsDashboard = dynamic(
  () => import("@/components/AnalyticsDashboard"),
  {
    loading: () => <div className="animate-pulse h-96 bg-gray-200 rounded" />,
  }
);

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <AnalyticsDashboard />
    </div>
  );
}

Disabling SSR for Client-Only Components

Some components (e.g., those relying on window or canvas APIs) can't render on the server:

const MapView = dynamic(() => import("@/components/MapView"), {
  ssr: false,
  loading: () => <div className="h-96 bg-gray-100 rounded">Loading map...</div>,
});

Preloading Chunks

You can preload lazy components before the user needs them — for example, on hover or on route prefetch:

const EditModal = lazy(() => import("./EditModal"));

// Preload when the user hovers the edit button
function preloadEditModal() {
  import("./EditModal");
}

function Toolbar() {
  return (
    <button
      onMouseEnter={preloadEditModal}
      onClick={()=> setShowModal(true)}
    >
      Edit
    </button>
  );
}

This eliminates the loading delay for components the user is likely to need next.

Measuring Bundle Impact

Use your bundler's analysis tools to identify large chunks:

// next.config.js — enable bundle analyzer
const withBundleAnalyzer = require("@next/bundle-analyzer")({
  enabled: process.env.ANALYZE === "true",
});

module.exports = withBundleAnalyzer({});

// Run: ANALYZE=true pnpm build

Look for:

  • Large third-party libraries that could be dynamically imported
  • Page-specific code that ended up in the shared bundle
  • Duplicate dependencies across chunks