Articles

Code Splitting Explained: Smaller JavaScript Bundles

Code splitting breaks one large JavaScript bundle into smaller chunks loaded on demand. How bundlers do it, the main strategies, and the trade-offs.

Takina Takina · · 5 min read
A component tree shown in a code editor

Code splitting is the practice of breaking an application’s JavaScript into several smaller files, called chunks, that the browser loads only when they are needed, instead of shipping one large bundle on the first page view. The user downloads, parses, and executes the code for the page they are looking at, and everything else arrives later or not at all. It is one of the most effective ways to cut startup cost in a single-page app.

Why one big bundle hurts

Bundlers originally existed to combine many modules into a single file, because older HTTP setups made dozens of small requests expensive. As apps grew, that single file grew with them. Every route, modal, chart library, and admin screen ended up in the same download.

JavaScript is unusually expensive per byte. The browser has to download it, then parse and compile it, then execute it, all largely on the main thread. A large bundle therefore delays first render and makes the page slow to respond to the first click, which shows up directly in the metrics covered in our Core Web Vitals guide. Code splitting attacks the problem at the root: code that is not shipped costs nothing.

How it works under the hood

The mechanism is the dynamic import() expression. A static import at the top of a file tells the bundler “this module is required up front.” A dynamic import() returns a promise and tells the bundler “this module can be loaded later.”

button.addEventListener("click", async () => {
  const { openEditor } = await import("./editor.js");
  openEditor();
});

When the bundler sees import("./editor.js"), it creates a separate chunk containing editor.js and everything only it depends on. At build time it emits a small runtime that knows each chunk’s filename. At run time, the call fetches the chunk over the network, evaluates it, and resolves the promise with the module.

Modern bundlers such as Vite, Rollup, webpack, and esbuild all treat dynamic imports as split points. Our esbuild vs Rollup vs webpack comparison covers how their chunking options differ.

The main splitting strategies

Route-based splitting

The most common and usually most valuable approach. Each route becomes its own chunk, so visiting the home page does not download the settings page. Most frameworks with file-based routing do this automatically. In React, the manual version looks like:

const Settings = lazy(() => import("./routes/Settings"));

<Suspense fallback={<Spinner />}>
  <Settings />
</Suspense>

The fallback is shown while the chunk loads; see React Suspense explained for how that boundary works.

Component-based splitting

Heavy components that are not visible on first render, such as a rich-text editor, a chart, a map, or a modal, are good candidates. Load them on interaction or when they scroll into view, the same idea as lazy-loading images applied to code.

Vendor splitting

Third-party libraries change far less often than application code. Putting them in a separate chunk means a deploy that only touches your own code leaves the vendor chunk’s filename, and therefore its cache entry, unchanged. Returning visitors download only what actually changed.

Shared-chunk extraction

When two lazily loaded routes both import the same utility module, the bundler can move it into a shared chunk rather than duplicating it in both. Bundlers do this automatically, with tunable thresholds for minimum chunk size and how many importers a module needs before it is extracted.

Code splitting vs tree shaking

The two are often mentioned together but solve different problems.

Code splittingTree shaking
GoalLoad code laterNever ship unused code
Unit of workChunks of modulesIndividual exports
TriggerDynamic import(), route configStatic analysis of ES module imports
Effect on total bytesRoughly the same, spread outSmaller overall
Effect on first loadSmallerSmaller

Tree shaking removes dead code from the build entirely. Code splitting keeps all live code but defers the parts that are not needed yet. A well-optimized app uses both.

The trade-offs

Splitting is not free, and over-splitting is a real failure mode.

  • Request waterfalls. If route A lazily loads component B, which lazily loads library C, the browser discovers each chunk only after the previous one has executed. Three sequential round trips can be slower than one larger file.
  • Loading states everywhere. Each split point needs a fallback UI. Too many produce a page that flickers through spinners.
  • Tiny chunks. Chunks of a few hundred bytes cost more in request overhead and compression inefficiency than they save. Bundlers merge small chunks for this reason.
  • Interaction latency. Loading code on click means the first click waits for a network request. Prefetching mitigates this.

Preloading and prefetching

Two resource hints soften the waterfall problem:

  • <link rel="modulepreload"> (or preload) tells the browser to fetch a chunk now because it will be needed for the current page. Bundlers often inject these automatically for a route’s known dependencies, which flattens waterfalls into parallel downloads.
  • <link rel="prefetch"> tells the browser to fetch a chunk at low priority because it will probably be needed for a future navigation. Many routers prefetch a route’s chunk when its link enters the viewport or is hovered.

Good chunk filenames include a content hash, so they can be served with long-lived cache headers. Our guide to HTTP caching headers explains why hashed filenames and immutable caching pair so well.

How to find what to split

Start with measurement, not guesswork. A bundle analyzer, available as a plugin for every major bundler, renders the output as a treemap so you can see which libraries dominate each chunk. Look for large dependencies used on only one screen, code behind feature flags or admin roles, and polyfills that modern browsers do not need. Then check the coverage panel in your browser’s developer tools to see how much of the loaded JavaScript actually executed on first load.

The takeaway

Code splitting turns one monolithic bundle into chunks loaded on demand, using dynamic import() as the split point. Split by route first, then by heavy components, and keep vendor code in its own cacheable chunk. Avoid splitting so finely that you create waterfalls and spinner-filled pages, and use preload and prefetch hints to fetch chunks before the user is left waiting.

Takina Takina · · 4 min read

What Is requestAnimationFrame? Smooth JS Animation

requestAnimationFrame schedules a callback right before the browser repaints, syncing JavaScript animation to the display's refresh rate. How it works.

#JavaScript #Frontend #Performance
Takina Takina · · 5 min read

Debounce vs Throttle: Rate-Limiting Events in JS

Debounce and throttle both tame rapid-fire events, but differently — debounce waits for a pause, throttle enforces a steady rate. When to use each.

#JavaScript #Frontend #Performance