Skip to the content.

Code splitting: ship the JavaScript this page needs, not all of it

Code splitting: ship the JavaScript this page needs, not all of it

A single bundle has one fatal property: to see the login page, the user downloads the admin dashboard, the charting library, the rich-text editor, and every route they will never visit. The browser must parse and compile all of it before the app is interactive, so the cost is not only bytes on the wire — it is main-thread time. Code splitting breaks that one file into pieces the app loads on demand: the first screen ships only its own code, and the rest arrives when the user actually navigates to it.

One monolithic bundle versus a small entry chunk plus lazy chunks On the left a single large bundle the browser must download before interactive. On the right a small entry chunk loads first, and route chunks for dashboard, editor and settings load on demand. one bundle app.js everything, up front split bundles entry dashboard editor settings route chunks load on demand
The entry chunk boots the app; each route's code is a separate chunk fetched the moment it is needed, not before.

The dynamic import is the split point

Bundlers split at one syntactic marker: the dynamic import(). Where a static import pulls code into the current chunk, import() returns a promise and tells the bundler “put this in its own file and fetch it at runtime.” In a framework this is wrapped in a lazy-loading helper so a whole route becomes a chunk:

import { lazy, Suspense } from "react";

// Dashboard and its deps become a SEPARATE chunk, fetched on first render
const Dashboard = lazy(() => import("./routes/Dashboard.jsx"));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Dashboard />           {/* the chunk downloads when this mounts */}
    </Suspense>
  );
}

The Suspense fallback is not optional polish — it is what the user sees during the network round-trip for the chunk, so a lazy boundary without a good fallback just trades a slow start for a blank flash.

Prefetch so the split is invisible

Splitting adds a delay at the moment of navigation: the chunk has to arrive before the route can render. You hide that delay by fetching the chunk before the click, during idle time or on hover — the code is ready by the time the user commits:

// warm the dashboard chunk when the link is hovered, before the click
link.addEventListener("mouseenter", () => {
  import("./routes/Dashboard.jsx");   // browser caches it; navigation is instant
}, { once: true });

Split by route first, then by weight

Not every import() earns its round-trip. The wins come from two places: split by route, because a user on the login page genuinely does not need the dashboard; and split out heavy, rarely-used dependencies — a charting library, a markdown editor, a date picker — that would otherwise inflate the entry chunk for everyone. Over-splitting has its own cost: dozens of tiny chunks mean dozens of requests and worse compression, so there is a floor below which a chunk is not worth its own file. The discipline is to set a bundle budget and let it fail the build when the entry chunk crosses it, which is exactly what the bundle-budget exercise makes you do — a number that turns “ship less JavaScript” from a wish into a check.