Bundle Splitting
Stop shipping the whole app up front. Split by route and on demand so users download only what they need.
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.
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.
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.
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:
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:
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:
// 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"));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.
import() do?- →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()(orReact.lazy). - →It's a trade-off — don't split small, always-needed code behind an extra request.