Frontend Performance/Loading Less, Sooner
Lesson 11 of 20 · Episode 11

Bundle Splitting

Stop shipping the whole app up front. Split by route and on demand so users download only what they need.

Code splittingLazy loadingChunks
Watch on YouTube ↗

Now we move from measuring performance to fixing it. The first and biggest lever for most apps: stop shipping one giant JavaScript file. Bundle splitting breaks your code into pieces so the browser downloads — and the main thread executes — only what the user actually needs right now.

Background

What a bundler does

Tools like Webpack, Vite, and Rollup take your source files, trace how they import one another into a dependency graph, and emit static assets — merged JS and CSS — that the browser loads. By default that often means one big bundle. The bundler is also where you control how it gets split.

The problem

Where the time goes

A JavaScript file isn't free the moment it arrives. The browser downloads it, parses it, compiles it, then executes it. Modern engines parse/compile fast — the two expensive ends are download (worse on slow networks) and execution (which blocks the main thread, worse on cheap phones). More code = more of both. So the goal is simple: ship less JS up front.

The core idea

Split it: monolith vs route-split

The classic win is route-based splitting: each page gets its own chunk, so the Home page never pays to download the Profile and Settings code. Toggle the two strategies and watch Time to Interactive collapse:

InteractiveOne big bundle vs. route-split
Initial download
1.5 MB
Time to Interactive
1.90s
Main-thread blocked
~800 ms
main.js · 1.5 MB
parse
execute
Interactive 1.90s
Everything ships in one 1.5 MB bundle, so the main thread is stuck parsing + executing code the user may never reach. Interactive doesn't happen until 1.9 s.
The monolith makes the user download and execute everything before the page is interactive. Splitting ships only the current route, and loads the rest on demand.
Splitting + laziness

Lazy-load on demand

Splitting pairs with lazy loading: don't just break code into chunks — only fetch a chunk when it's actually needed. A heavy, rarely-used feature like an emoji picker is the perfect candidate. Bundle it eagerly and every user pays; lazy-load it and only those who open it do:

InteractiveFetch the emoji picker only when clicked
Initial bundle
420 KB
emoji-picker.js
not loaded
Type a message…
Lazy: the picker is a separate chunk. Click 😀 and watch it fetch only when needed.
Eager: 84 KB in the main bundle for everyone. Lazy: a separate chunk fetched on first click — the initial bundle stays small.
In code

How: the dynamic import

The modern way to create a split point is the dynamic import(). A static import is bundled eagerly; swapping it for import() tells the bundler “make this a separate chunk and only fetch it when this code runs.” It returns a Promise, so await works:

static (eager) → dynamic (lazy chunk)
// Eager: bundled into main, downloaded for everyone
import { EmojiPicker } from "./EmojiPicker";

// Lazy: its own chunk, fetched only when the handler runs
async function openPicker() {
  const { EmojiPicker } = await import("./EmojiPicker");
  render(EmojiPicker);
}

// In React, the same idea via React.lazy + Suspense:
const Profile = React.lazy(() => import("./routes/Profile"));
Find candidates with the Coverage tab
Chrome DevTools' Coverage tab shows how much of each file went unused on load. High unused % is a hint to split — but check case-by-case: “unused” may just be a code path the user hasn't triggered yet.
Don't over-split

When NOT to split

Every split has a cost: the chunk has its own request and load time. If navigating to Profile must fetch profile.js before it can even start the API call for the user's data, the user may feel a delay that the monolith didn't have. Split aggressively for big, conditional, or rarely-used code; keep small, always-needed code in the initial bundle so interactions feel instant.

Watch out
Bundle splitting is a trade-off, not a free win. Always ask: does this split improve the experience the user actually has, or just move the wait somewhere else?
Q1Sort each scenario
Good candidate to split out, or keep in the initial bundle?
Emoji picker opened by <10% of users
The entire Settings route
The site header shown on every page
A 300 KB charting lib used only on /analytics
Q2Multiple choice
Which two stages are the main cost of shipping a large JS bundle?
Q3Multiple choice
What does swapping a static import for a dynamic import() do?
Q4Multiple choice
When can splitting actually make the experience worse?
Key takeaways
  • Bundlers build a dependency graph and emit assets; by default, often one big bundle.
  • Big JS hurts at download (network) and execution (main thread).
  • Route-based splitting + lazy loading ship only what's needed now.
  • The split point is a dynamic import() (or React.lazy).
  • It's a trade-off — don't split small, always-needed code behind an extra request.
← Previous
10. Profiling with Chrome DevTools
Next →
12. Action on Visibility