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 everyoneimport{EmojiPicker}from"./EmojiPicker";// Lazy: its own chunk, fetched only when the handler runsasyncfunctionopenPicker(){const{EmojiPicker}=awaitimport("./EmojiPicker");render(EmojiPicker);}// In React, the same idea via React.lazy + Suspense:constProfile=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.jsbefore 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?
Q1Multiple choice
A dependency is 'only 40 KB gzipped,' so a teammate keeps it in the initial bundle. Why is gzip size a misleading measure of its cost?
Q2Multiple choice
You lazy-load the Profile route. Now clicking 'Profile' feels SLOWER: it fetches profile.js, and only then starts the API call for the user's data. What happened, and a fix?
Q3Multiple choice
A build tool auto-splits every module into its own chunk. On a page with 60 small modules, this makes load SLOWER. Why?
Q4Sort each scenario
Split out, or keep in the initial bundle? One is too small to be worth its own request.
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
A 2 KB date helper imported on every page
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.