Skip to content

Code Splitting and Lazy Loading

Aman Kumar Singh7 min read
Part 11 of 50From the React & Next.js Complete Guide series
Code Splitting and Lazy Loading — article by Aman Kumar Singh

In Avoiding Unnecessary Re-Renders, we dealt with a component tree that was already loaded but rendering more than it needed to. This article deals with the step before that: how much JavaScript the browser has to download and parse before your app can render anything at all. Re-render discipline is wasted effort if the user is still staring at a blank screen waiting for a five-hundred-kilobyte bundle to arrive.

This is part of the React & Next.js Complete Guide series, which started with React Fundamentals for Professionals. Code splitting is one of the few performance techniques that's genuinely free once you set it up correctly. The tradeoff is timing, not correctness: you decide when code gets downloaded instead of shipping everything up front regardless of what the user needs.

I want to be upfront about the tradeoff, though, because this technique gets oversold. Splitting your bundle adds moving parts: loading states, error boundaries, and a network waterfall that didn't exist before. If your app is genuinely small, a single bundle is simpler and probably faster than a dozen tiny chunks each paying their own round-trip cost. Code splitting earns its complexity once the bundle is large enough that users are paying for code paths they'll never touch on a given visit.

The problem: one bundle for every possible page

A typical SaaS app has a marketing site, a dashboard, a billing page, an admin panel, and maybe a rich text editor or charting library buried somewhere deep in settings. Without any splitting, a bundler like Webpack or Turbopack concatenates all of that into one or a few large JavaScript files, and the browser has to fetch, parse, and execute the whole thing before the first meaningful paint, regardless of whether the visitor ever opens the admin panel.

There's more to the cost than download time. Parsing and compiling JavaScript takes CPU time proportional to its size, and that cost hits hardest on mid-range mobile devices, not the developer's laptop where everything feels instant. A bundle that feels fine locally can feel sluggish on a phone with a slower processor and a throttled connection.

Code splitting breaks that single bundle into multiple chunks that load on demand: when a route is visited, when a component actually mounts, or when the browser is idle and can afford to prefetch something the user will probably need next. The initial load only pays for the code that renders the first screen.

React.lazy and Suspense: the baseline mechanism

React.lazy combined with Suspense is the core primitive underneath most code-splitting patterns in React, including the ones Next.js builds on top of.

import { lazy, Suspense } from "react";

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

function SettingsPage() {
  return (
    <Suspense fallback={<SettingsSkeleton />}>
      <BillingSettings />
    </Suspense>
  );
}

The dynamic import() call is what tells the bundler to split BillingSettings and its dependencies into a separate chunk instead of inlining them into the parent bundle. React.lazy wraps that dynamic import so React knows how to render a fallback while the chunk is still in flight, and Suspense is what actually renders that fallback.

The pitfall here is forgetting that Suspense boundaries cover the whole subtree, not just the lazy component. If BillingSettings renders instantly from cache on a second visit, the fallback never shows, which is correct. But a single Suspense boundary wrapped around several lazy components keeps the entire section hidden until every one of them resolves, even if some load faster than others. Scope boundaries to match how you actually want loading states to appear: one skeleton per meaningful unit of UI, not one giant skeleton for half the page.

Route-based splitting is where most of the gain lives

Route boundaries are where splitting code pays off the most, because a user visiting /dashboard almost never needs the code for /admin/audit-logs in the same request. Most routing setups, whether it's React Router or Next.js's file-based router, split by route automatically or with minimal configuration.

import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";

const Dashboard = lazy(() => import("./pages/Dashboard"));
const AdminPanel = lazy(() => import("./pages/AdminPanel"));
const BillingPage = lazy(() => import("./pages/BillingPage"));

function AppRoutes() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/admin" element={<AdminPanel />} />
        <Route path="/billing" element={<BillingPage />} />
      </Routes>
    </Suspense>
  );
}

In Next.js with the App Router, this happens by default: every route segment is already its own chunk, and you rarely need to hand-write React.lazy for pages. Where you still reach for explicit splitting in Next.js is component-level, using next/dynamic, usually for something heavy that isn't needed on first paint.

import dynamic from "next/dynamic";

const RevenueChart = dynamic(() => import("../components/RevenueChart"), {
  loading: () => <ChartSkeleton />,
  ssr: false,
});

ssr: false matters when the component depends on browser-only APIs, like a charting library that reads window dimensions or a rich text editor that manipulates the DOM directly. Rendering it on the server would either throw or produce output the client immediately has to discard and re-render, which defeats the point of server rendering in the first place. Reserve ssr: false for genuinely client-only code; using it as a default habit throws away the SEO and first-paint benefits Server Components and SSR are there to provide.

What to split, and what not to

Not everything deserves its own chunk. Splitting decisions should be driven by two questions: is this code needed for the initial view, and is it large enough to matter.

Good candidates for lazy loading:

  • Modals, drawers, and dialogs that only render after a user action, since their content isn't needed until then.
  • Rich text editors, PDF viewers, and charting libraries, which tend to be some of the heaviest third-party dependencies in a typical SaaS app.
  • Admin-only or role-gated sections that most users never visit.
  • Rarely used settings pages buried a few clicks deep.

Poor candidates:

  • Anything above the fold on the initial page. Splitting the hero section of your landing page just adds a loading flicker for content that was going to be visible immediately anyway.
  • Tiny components. The overhead of an extra network request and a Suspense fallback can cost more than the few kilobytes you saved by splitting a small file.
  • Code paths that are used on nearly every page. If eighty percent of visitors hit a component, splitting it just delays the inevitable download and adds a waterfall for most of your traffic.

A bundle analyzer, whether that's webpack-bundle-analyzer or Next.js's built-in @next/bundle-analyzer, is worth running before deciding what to split. Guessing which dependency is heavy is unreliable; a date library or an icon set can quietly account for a disproportionate share of your bundle.

npm run build -- --profile
npx webpack-bundle-analyzer .next/analyze/client.json

Production pitfalls: waterfalls, layout shift, and stale chunks

Three issues show up repeatedly once code splitting is in production rather than in a demo.

The first is request waterfalls. If a lazy component itself lazily imports another, the browser ends up making a chain of sequential requests instead of parallel ones, each waiting on the previous chunk before it knows what to fetch next. Flattening deeply nested lazy imports, or preloading known-next chunks on hover or idle time, avoids the worst of this.

The second is layout shift from loading states that don't match the dimensions of the real content. A skeleton that's a different height than the component it's standing in for causes the surrounding layout to jump when the real content mounts, which is exactly the kind of thing Core Web Vitals penalizes. Size your fallbacks to match, even approximately, the real component's footprint.

The third, and the one that causes the most confusing support tickets, is stale chunk errors after a deploy. If a user has a tab open when you ship a new build and then navigates to a route whose chunk hash changed, the browser requests a file that no longer exists because the old build's assets were replaced. This shows up as a ChunkLoadError or a blank screen, and the fix is almost always a full-page reload rather than a client-side navigation. Wrap route-level Suspense boundaries in an error boundary that catches this specific failure and prompts a reload before it becomes a real incident.

class ChunkErrorBoundary extends React.Component<
  { children: React.ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError(error: unknown) {
    const message = error instanceof Error ? error.message : "";
    if (message.includes("Failed to fetch dynamically imported module")) {
      return { hasError: true };
    }
    throw error;
  }

  componentDidUpdate() {
    if (this.state.hasError) {
      window.location.reload();
    }
  }

  render() {
    return this.props.children;
  }
}

Key takeaways

  • Code splitting trades a single large bundle for multiple smaller chunks loaded on demand, but it adds loading states, error handling, and network waterfalls that a single bundle doesn't have.
  • `React.lazy` and `Suspense` are the underlying primitives; scope `Suspense` boundaries to individual meaningful UI units instead of wrapping large sections in one shared fallback.
  • Route-based splitting delivers most of the benefit with the least effort, and Next.js's App Router does this automatically per route segment.
  • Use `next/dynamic` with `ssr: false` only for genuinely client-only code, like DOM-dependent editors or charting libraries; overusing it throws away SSR benefits.
  • Run a bundle analyzer before deciding what to split. Split heavy, rarely used code (modals, admin panels, editors); leave small or above-the-fold components alone.
  • Handle stale chunk errors after deploys with an error boundary that prompts a reload, or users on an open tab will hit confusing blank-screen failures.

Frequently asked questions

Does code splitting make my app faster, or just move the cost around?

Both, depending on what you split. Splitting rarely used code genuinely reduces total bytes downloaded on a typical visit, since the user never fetches chunks for pages they don't open. Splitting something used on nearly every page mostly just delays a download that was going to happen anyway, and adds a network round-trip on top.

Should I lazy load every component to be safe?

No. Small components add proportionally more overhead (an extra request, a `Suspense` fallback render, some bundler bookkeeping) than they save in bytes. Reserve lazy loading for components that are both large and conditionally rendered.

What's the difference between React.lazy and next/dynamic?

`React.lazy` is the React primitive for code-split components rendered inside a `Suspense` boundary. `next/dynamic` builds on the same underlying mechanism but adds Next.js-specific options, most notably `ssr: false` for client-only rendering and a `loading` option that doesn't require a separate `Suspense` wrapper.

Why does my app show a ChunkLoadError after I deploy a new version?

The user's browser has an old build's JavaScript loaded, and it tries to fetch a chunk by a filename hash that belonged to the previous deployment, which the new deployment no longer serves. This is a routine side effect of hashed asset filenames; handle it with an error boundary that triggers a full page reload rather than treating it as an application bug.

Do Server Components make code splitting less necessary?

They shift where the benefit comes from. Server Components never ship their own code to the client at all, which reduces the client bundle more fundamentally than lazy loading does. But any component that has to run on the client, forms, interactive charts, editors, still benefits from splitting if it's heavy and not needed on first paint.

Is it worth setting up prefetching for lazy-loaded routes?

Often yes, for predictable navigation paths. Triggering the dynamic `import()` on link hover or during browser idle time means the chunk is often already cached by the time the user actually clicks, turning a visible loading state into an invisible one. It's not worth the added complexity for routes users rarely navigate to in sequence.

Related articles

Core Web Vitals for React Apps — Aman Kumar Singh
Image and Asset Optimization — Aman Kumar Singh
SEO with the Next.js Metadata API — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.