Code splitting and bundling
Breaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
The problem: one bundle for everything
Picture an expense app with a login page, a list of expenses, a receipt scanner, a reports page with charts, and an admin area that one user in a hundred ever opens. The code is written as about 900 small files, called modules, plus 60 packages from npm.
A browser can't load 900 files efficiently, so a bundler (Vite, webpack, Rollup, esbuild, Rspack, or Turbopack) joins the modules into a few files the browser can fetch. With no splitting configured, it produces one file: main.js, about 450 KB compressed and 1.6 MB once unzipped. Every visitor downloads all of it, even to see the login form.
On the team's laptops the app opens in under a second, so nobody notices. Then the support queue fills with complaints from field staff on mid-range Android phones: the login page shows, but tapping the email field does nothing for two seconds. The problem is not the network. It's what happens after the bytes arrive.
JavaScript is paid for three times
An image is downloaded and decoded, mostly off the main thread. JavaScript is far more expensive per byte, because the browser has to do three things with it before the page can respond to the user.
- Download. The compressed bytes travel over the network. On Lighthouse's mobile profile (1.6 Mbps down, 150 ms round trip), 450 KB takes about 2.4 seconds to arrive.
- Parse and compile. The engine reads the unzipped code, which is usually 3 to 4 times bigger than what was downloaded, checks its syntax, and turns it into bytecode. Modern engines do part of this lazily and on background threads, but a large share still lands on the main thread.
- Execute. The code runs: modules evaluate their top-level statements, the framework builds the component tree, and the first render happens. This is pure main-thread work, and on today's engines it's often the biggest of the three.
The main thread also handles taps, typing, and scrolling. While it's busy parsing and running 1.6 MB of code, input waits in a queue: the freeze the field staff reported.
A rule of thumb you can say out loud
The estimation lesson gives a rule worth memorising: about 1 second of main-thread work per megabyte of unzipped JavaScript on a mid-range phone. A laptop needs about a quarter of that, and a budget phone about twice it. It's right within a factor of two, which is enough to make a decision.
Apply it to the expense app. 1.6 MB unzipped is about 1.6 seconds of main-thread work on a mid-range phone, on top of 2.4 seconds of download. The login page itself needs maybe 40 KB of its own code plus about 140 KB for React, the router, and shared helpers. Everything else is paid for by a user who only wanted to sign in.
Download about 2.4 s, main-thread work about 1.6 s, total about 4.0 s.
Which user-facing numbers it hurts
- LCP (Largest Contentful Paint, when the main content shows). On a client-rendered page nothing meaningful paints until the bundle has run, so bundle size adds directly to LCP.
- INP (Interaction to Next Paint, how fast the page answers input). Big bundles create long tasks (over 50 ms) during start-up, and a tap that lands during one waits for it.
- Hydration time on server-rendered pages. The HTML shows early, but buttons don't work until the JavaScript has attached handlers. A large bundle stretches that dead zone.
Code splitting is the fix. Instead of one file, the bundler produces many chunks (output files), cut along lines you choose: one per page, one for the chart library, one for the admin area. Each page loads only what it needs now, and the rest arrives later or never. With tree shaking (removing code nobody imports) and good caching, it's the biggest lever on load time for app-like sites.
Check your understanding · Exercise 1
A returning visitor loads the expense app, and main.js comes from the HTTP cache as a fresh hit. Which costs does the visitor still pay? Select all that apply.
The mental model: a graph cut into pieces
To understand splitting, first picture what a bundler sees. It starts at an entry point, usually src/main.tsx, reads every import in it, follows each one to another file, reads that file's imports, and so on. The result is a module graph: every file your app can reach, with an arrow for every import.
Without splitting, the bundler writes every module in the graph into one output file. With splitting, you tell it where to cut the graph. Each cut starts a new chunk, and the bundler works out which modules belong to which chunk.
import() cuts the graph and starts a new chunk. A module that several async chunks need goes into a shared chunk, so it never downloads twice.Two kinds of import, two kinds of edge
JavaScript has two ways to import a module, and the bundler treats them differently. That difference is the whole mechanism of code splitting.
- Static import:
import { formatMoney } from "./money"at the top of a file. It means "I can't run without this". The bundler puts the imported module in the same chunk as the importer, or in a chunk that's guaranteed to load before it. - Dynamic import:
import("./ReportsPage")called as a function, anywhere in your code. It returns a promise and means "I may need this later". The bundler treats it as a cut: the target and everything it statically imports go into a new chunk, fetched when the call runs.
So the rule is short: static imports glue, dynamic imports cut. Everything reachable from the entry through static imports alone is the initial chunk, which every page pays for. Each dynamic import starts an async chunk, paid for only by the users who reach that code.
Modules used by more than one chunk
Real graphs aren't trees. The reports page and the admin page both import the chart library. If the bundler copied it into both chunks, a user visiting both would download it twice. Instead, bundlers move a module that several async chunks need into a shared chunk, and make each of those chunks load it first.
This is automatic in Vite (Rolldown since Vite 8, Rollup before), webpack, Rspack, esbuild, and Turbopack. You can steer it, for example by forcing react and react-dom into one long-lived chunk, but the default grouping by "which chunks use this module" is a good starting point.
An everyday picture
Think of a travel guide series. You wouldn't carry a guide to all of Europe for a weekend in Paris. The publisher splits the series by city (route splitting), sells the rarely changing maps as a booklet you keep between trips (vendor chunks), and leaves out places that no longer exist (tree shaking). You pick up the Rome booklet at the station when you actually go to Rome (dynamic import).
| Term | What it means | Example |
|---|---|---|
| Module | One source file with imports and exports | src/pages/Reports.tsx |
| Entry | Where the bundler starts walking the graph | src/main.tsx |
| Chunk | One output file holding one or more modules | assets/Reports-B3k9xQ.js |
| Initial chunk | Code every page load needs before the app runs | React, router, layout, home page |
| Async chunk | Code loaded later by a dynamic import() | Reports page, chart library |
| Shared chunk | Modules used by several async chunks, pulled out once | Chart library used by Reports and Admin |
Check your understanding · Exercise 2
main.tsx statically imports App.tsx. App.tsx statically imports Header.tsx and dynamically imports Reports.tsx, which statically imports charts.ts. Which modules are in the initial chunk? Pick one.
How dynamic import() works, step by step
import() is part of the JavaScript language, supported in every major browser since early 2020. You pass it a module path and it returns a promise of that module's exports. Bundlers recognise the call at build time and turn it into a request for a chunk file.
// Before: the PDF exporter is in the initial chunk for everyone
// import { exportPdf } from "./pdf/exportPdf";
exportButton.addEventListener("click", async () => {
exportButton.disabled = true;
try {
const { exportPdf } = await import("./pdf/exportPdf");
await exportPdf(currentReport);
} finally {
exportButton.disabled = false;
}
});
What happens when the call runs
Follow one click on the Export button in the built app. The numbers assume a mid-range phone on 4G, with the chunk not yet cached.
- The click handler reaches
import("./pdf/exportPdf"). At build time the bundler replaced that path with the real file name, such asassets/exportPdf-Dq81aZ.js, and the list of chunks it depends on. - The bundler's small runtime requests the chunk and its dependencies in parallel: Vite adds
link rel="modulepreload"tags for the dependencies, and webpack addsscripttags. - The CDN answers. With a warm connection, 60 KB compressed arrives in about 100 to 300 ms.
- The browser parses and compiles the chunk, then evaluates its modules in dependency order. For 200 KB unzipped that's about 200 ms on a mid-range phone, by the 1 second per MB rule.
- The promise resolves with the module's exports. Your code calls
exportPdf. On the next click the module is already in memory andimport()resolves almost at once.
import() of the same path skips all of this.Two details matter. First, a module is fetched and evaluated only once: a second import() of the same path returns the same module. Second, the whole round trip happens after the click, which is why the next chapter is about starting the download earlier.
React.lazy and Suspense
Components need one more piece: something to show while the chunk loads. React's lazy() wraps a dynamic import and returns a component. The first time it renders, it suspends: it tells React "not ready yet", and React shows the fallback of the nearest Suspense boundary above it until the chunk arrives.
import { lazy, Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
// lazy() expects a module with a default export
const ReportsPage = lazy(() => import("./pages/ReportsPage"));
// For a named export, map it to default
const MapView = lazy(() => import("./MapView").then((m) => ({ default: m.MapView })));
function Reports() {
return (
<ErrorBoundary fallback={<RetryPanel />}>
<Suspense fallback={<ReportsSkeleton />}>
<ReportsPage />
</Suspense>
</ErrorBoundary>
);
}
- Call
lazy()once, at module level. Calling it inside a component creates a new component type on every render, so React remounts it and its state is lost. - Place the boundary where a skeleton makes sense. One boundary around the whole router shows a full-page fallback on every navigation. A boundary around the content area keeps the header and sidebar on screen.
- Wrap navigations in a transition. If a route change is inside
startTransition(React Router 7 and the Next.js App Router do this for you), React keeps the old page visible while the new chunk loads instead of flashing the fallback. - Add an error boundary. If the network drops, the import promise rejects and the lazy component throws. Without a boundary, React unmounts the whole tree.
Chunk names and variable paths
Bundlers name chunks after the source file plus a content hash. webpack also reads comments inside the call, such as import(/* webpackChunkName: "reports" */ "./Reports"). Vite has no such comments and names chunks after the file.
If the path contains a variable, like import("./locales/" + lang + ".json"), the bundler can't know which file you mean. webpack creates one chunk for every file that matches the pattern and picks the right one at run time. That's handy for translations, but a big folder can quietly become hundreds of chunks. Vite's import.meta.glob makes the same idea explicit.
Check your understanding · Exercise 3
A developer reports that a lazy-loaded settings panel loses its form state every time the parent re-renders. Which code causes this? Pick one.
Where to cut: routes, components, and triggers
Knowing how to split is easy; knowing where is the real skill. A good cut removes a lot of code from the first load and costs the user little or no waiting later. There are two kinds of boundary, and four common moments to start the load.
Route splitting: the first cut to make
The safest, most valuable split is one chunk per route. A visitor on the home page downloads the home page's code plus what every page shares, and nothing for settings or reports. Users already expect a short pause when they move to a new page, so the cost of loading a chunk then is easy to hide.
Most frameworks do this for you. Next.js, Nuxt, SvelteKit, and React Router's framework mode produce a chunk per route from your file layout, and Angular does it for routes declared with loadComponent. In a hand-built React app with React Router, you wrap each page in lazy() or use the router's lazy route property, which loads the route's component and its data loader together.
import { createBrowserRouter } from "react-router";
import Home from "./routes/home";
export const router = createBrowserRouter([
{ path: "/", Component: Home }, // small, in the initial chunk
{ path: "/expenses", lazy: () => import("./routes/expenses") },
{ path: "/reports", lazy: () => import("./routes/reports") }, // brings the chart library
{ path: "/admin/*", lazy: () => import("./routes/admin") },
]);
Component and loader; visitors who never open Reports never download the charts.Component splitting: heavy pieces inside a page
Inside one route there are often pieces the first screen doesn't need. Good candidates are big, not visible at first, and never touched by many users. Typical examples are a rich text editor (often 100 to 300 KB minified), a map, a chart library, a video player, an emoji picker, and any dialog that one user in twenty opens.
Bad candidates are small components (a 3 KB button gains nothing from its own request) and anything in the first viewport, because splitting it delays what the user came to see.
Initial chunk 450 KB, about 1.6 s of main-thread work.
When to start the load
A split chunk has to be fetched at some moment. Pick the moment by asking how likely the user is to need it and how bad it is to wait for it. These patterns are often called import on interaction and import on visibility.
Decision When should a split chunk start loading?
What we pick: Combine them. Load routes on navigation and prefetch likely next routes when idle or when links come into view. Load heavy dialogs on intent. Load below-the-fold widgets on visibility. Use a plain click trigger only for rare, tolerant features like an export.
let editor: Promise<typeof import("./RichEditor")> | undefined;
const loadEditor = () => (editor ??= import("./RichEditor"));
// Intent: start before the click
button.addEventListener("pointerenter", loadEditor, { once: true });
button.addEventListener("focus", loadEditor, { once: true });
button.addEventListener("click", async () => {
const { openEditor } = await loadEditor(); // usually already loaded
openEditor();
});
??= keeps one shared promise, so hover, focus, and click never start two downloads.Visibility loading follows the same shape: render a placeholder of the right height, observe it, and import when it comes within about 300 to 600 px of the viewport. Next.js applies the idea to routes: in production builds only, its Link component prefetches a route's code when the link scrolls into view.
Check your understanding · Exercise 4
A product page has a 220 KB 3D model viewer in a tab that about 15% of visitors open, and a 40 KB reviews widget far below the fold. What are good choices? Select all that apply.
Tree shaking: never shipping unused code
Splitting moves code to a later moment. Tree shaking removes code completely: exports that nothing in your app imports are dropped from the build. The name comes from shaking a tree so the dead leaves fall off. Every modern bundler does it in production builds, but only when your code and your packages allow it.
Why it needs ES modules
Tree shaking works on ES modules, the import and export syntax. Their imports are static: they sit at the top level, the names are fixed strings, and they can't change at run time. So at build time the bundler sees exactly which exports each file uses, and marks the rest as unused.
CommonJS, the older require() and module.exports format, is dynamic. require(name) can take a variable, and module.exports can be rewritten at any time. The bundler can't prove what's unused, so it keeps the whole module. Some bundlers recover a little from simple CommonJS patterns, but you shouldn't count on it.
parseCsv or deepClone. A require() of the same file would keep all four.// CommonJS build: the whole library ships, about 70 KB minified
import _ from "lodash";
const save = _.debounce(saveDraft, 500);
// ES module build: only debounce and its helpers ship, about 2 KB
import { debounce } from "lodash-es";
const save2 = debounce(saveDraft, 500);
Side effects and the sideEffects field
There's a catch. Some modules do something just by being loaded: they patch window, register a custom element, add a polyfill, or import a CSS file. That's a side effect. Even if you use none of the module's exports, removing it would change behaviour, so a careful bundler keeps any module it can't prove is free of side effects.
Proving it is hard, so packages can promise it instead. "sideEffects": false in a package's package.json tells the bundler: "any file of mine whose exports you don't use can be dropped entirely". If only some files have effects, list them, for example "sideEffects": ["*.css", "./src/polyfills.js"]. Your app's own package.json can carry the same field for your own files.
- The mistake that removes too much. Marking a package
"sideEffects": falsewhile it imports global CSS. The bundler drops the CSS import and styles vanish in production only. - The mistake that keeps too much. A top-level statement like
export const theme = createTheme(defaults). The call might have effects, so the bundler keeps it. Writing/*#__PURE__*/ createTheme(defaults)tells bundlers and minifiers the call is safe to drop ifthemeis unused. - Classes with static blocks or decorators often count as side effects, because that code runs when the module loads.
Barrel files
A barrel file is an index.ts that re-exports everything in a folder: export * from "./Button", export * from "./DataGrid", and so on. Importing one button from it is convenient, but it makes the bundler (and the dev server) load and analyse every file the barrel names.
In a production build with "sideEffects": false set, the unused files are usually dropped. Without it, or when one re-exported file has a top-level side effect, the whole folder can end up in your chunk. Barrels also slow dev servers and tests, which load every file.
What tree shaking can't do
- It can't remove code you import but rarely call. A chart library used on the reports page stays; that's what splitting is for.
- It works per export, not per line. A big class with 40 methods ships all 40 if you use one, because methods aren't separate exports.
- It can't see through dynamic access like
utils[name](). The bundler has to keep every export ofutils.
Check your understanding · Exercise 5
After switching to a new icon package that sets "sideEffects": false and ships ES modules, the bundle still contains all 1,500 icons. Which causes are plausible? Select all that apply.
Vendor chunks and cache stability
Splitting decides what a first visit downloads; caching decides what a returning visitor downloads after you deploy. They meet in one question: when you change one line of code, how many bytes must every returning user fetch again?
Content hashes make files cacheable forever
Production bundlers name each chunk after a hash of its contents: index-4f9a1c2e.js, vendor-react-b81d07aa.js. If the content changes, the name changes. If it doesn't, the name stays the same, build after build.
A file whose name changes whenever its content changes can never go stale. So it can be cached for a year with Cache-Control: public, max-age=31536000, immutable. Only the HTML, which names the current chunks, needs no-cache or a short lifetime; the HTTP caching lesson covers these headers.
Why third-party code gets its own chunk
Your own code changes with every deploy, maybe several times a day. React, the router, and your date library change a few times a year. If they share one chunk, every deploy gives it a new hash, and returning users download all of it again, including 140 KB of framework code that didn't change.
So the classic move is a vendor chunk: third-party code in its own file, with its own hash, kept apart from app code. Now a typical deploy changes only the app chunk, and returning users fetch a few dozen kilobytes instead of the whole app.
Returning user downloads 90 KB again; a first visit to the home page costs 470 KB.
The trap of one giant vendor chunk
Taken too far, the idea backfires. Put all of node_modules in one vendor chunk and the chart library used only on the reports page now loads on every page, because it's in the vendor file every page needs. And any upgrade of any package, even a tiny one, invalidates the whole vendor file.
Modern setups use granular chunks instead: the framework (React, React DOM, the router) in one stable chunk that every page needs, big libraries in their own chunks loaded only by the routes that use them, and small shared modules grouped by which routes share them. Next.js does this by default, and Vite's grouping by usage covers most of it. Add manual rules only when the analyzer shows a problem.
// vite.config.ts (Vite 8): pin the framework into one long-lived chunk
export default defineConfig({
build: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{
name: "framework",
test: /node_modules[\\/](react|react-dom|scheduler|react-router)[\\/]/,
},
],
// everything else: automatic grouping by usage
},
},
},
},
});
node_modules would recreate the giant vendor chunk. Vite 7 and earlier use rollupOptions.output.manualChunks for the same job.Keeping hashes stable
A vendor chunk only helps if its hash really stays the same when the vendor code doesn't change. A few things can break that without you noticing.
- Module ids that depend on order. Old webpack setups numbered modules in order, so adding one file renumbered the rest and changed every chunk. webpack 5 uses deterministic ids by default; check old configs for
moduleIds: "natural". - The chunk map inside a stable chunk. The bundler's runtime keeps a map from chunk names to hashed file names. If that map sits inside the vendor chunk, any change anywhere changes the vendor hash. Keep it in the entry chunk or a small runtime chunk (
runtimeChunk: "single"in webpack).
Check your understanding · Exercise 6
An app ships index.js (90 KB, app code) and vendor.js (380 KB, all of node_modules, including a 160 KB chart library used only on /reports). What changes would help? Select all that apply.
Over-splitting, waterfalls, and other failures
Splitting has costs of its own. Every chunk is a separate request, a separate wait, and a separate thing that can fail. Teams new to splitting often swing too far and end up slower than with one bundle.
The waterfall: chunks that discover chunks
A request waterfall is a chain where each request can only start after the previous one finishes, because it's the previous response that reveals what to fetch next. Splitting creates waterfalls easily. The page loads index.js; when it runs, the router lazy-loads ReportsPage.js; when that renders, a lazy ChartPanel.js asks for charts.js. Three round trips, one after another, before any chart appears.
On a 150 ms round trip that's at least 450 ms of pure waiting, plus the parse of each step, which can't overlap the next fetch. Loaded in parallel, the same code would cost one round trip.
modulepreload hint, or a static import inside the route chunk, flattens it.- Don't nest lazy boundaries on the critical path. If a route always shows the chart, import the chart statically inside the route chunk. Splitting both gains nothing and adds a round trip.
- Let the bundler preload dependencies. When a chunk is requested, Vite and webpack also request the chunks it statically depends on, in parallel. Without a bundler's help, add
link rel="modulepreload"tags so the browser doesn't discover each import one level at a time. - Load code and data together. Route loaders in React Router and Remix, and server components in Next.js, start the data fetch at the same time as the route chunk instead of after the component renders.
- Preload what you know. If the server knows the first page will need
ReportsPage.js, it can put amodulepreloadlink in the HTML, so the fetch starts beforeindex.jseven runs.
Too many tiny chunks
HTTP/2 and HTTP/3 carry many requests over one connection, so 30 requests no longer cost 30 connections. But each request still costs headers, a cache lookup, browser scheduling, and some work to set up each module. Compression also works worse on small files, because gzip and Brotli find fewer repeats in 2 KB than in 50 KB.
A sensible floor is around 10 to 20 KB compressed per async chunk. Bundlers have settings for this: webpack's splitChunks won't create a shared chunk smaller than minSize (20,000 bytes by default in production), and Rollup's output.experimentalMinChunkSize merges small chunks into others. If your build output lists hundreds of chunks under 1 KB, merge them.
Chunk load errors after a deploy
This is the failure most teams meet in production. A user opens the app at 9:00 and keeps the tab open. At 10:00 you deploy, and the old chunk files are deleted from the server.
At 10:15 the user clicks Reports. The old index.js still in their tab asks for ReportsPage-a1b2.js, which no longer exists. The import fails with a 404, and the user sees a blank area or a crash.
- Keep old assets around. Don't delete the previous builds' hashed files on deploy. Keeping them for a few days, or a few releases, fixes most cases.
- Catch the failure. Wrap lazy routes in an error boundary. Vite fires a
vite:preloadErrorevent onwindowwhen a dynamic import fails; webpack throws aChunkLoadError. A common response is one automatic full-page reload, guarded so it can't loop. - Retry once, carefully. A failed import on a flaky mobile network often works on the second try. But browsers have long remembered a failed module fetch for the life of the page, as the HTML spec required. The spec stopped caching HTTP errors in 2026, yet browsers update at different speeds, so a retry with the same URL may still fail. A full reload, or a query string such as
?retry=1on the chunk URL, avoids it.
Other things that break
| Problem | How you notice | Fix |
|---|---|---|
| Spinners flash on every navigation | Users see a skeleton for 50 ms on fast connections | Use transitions so the old page stays visible; prefetch likely routes |
| Two copies of a library | The analyzer shows the same package twice at different versions | Align versions with npm dedupe or package manager overrides |
| A split that saves nothing | The lazy module is also imported statically elsewhere | Remove the static import; Vite warns about this |
| Layout shift when a lazy widget appears | CLS rises on pages with lazy widgets | Reserve the widget's space with a fixed-size placeholder |
| Initial chunk slowly grows back | Bundle size creeps up release after release | A size budget in CI that fails the build, covered under bundle analysis |
Check your understanding · Exercise 7
A dashboard route is lazy-loaded. Inside it, a lazy ChartPanel imports a charting library, and the chart data is fetched in a useEffect after ChartPanel mounts. Every visitor to the route sees the chart. What improves time to chart? Select all that apply.
Shipping modern code: targets and module/nomodule
Splitting decides which code loads when. A second question is what that code looks like. The same source can compile to compact modern JavaScript or to longer, older JavaScript plus polyfills, and the difference can be 10 to 20 percent of the bundle.
Build targets and polyfills
A build target tells the compiler which browsers you support. If every target browser understands optional chaining, classes, and async functions, the compiler leaves them as written. If one doesn't, the compiler rewrites them into older syntax, which is longer and often slower, and a polyfill library adds missing built-ins like Array.prototype.at.
Most tools read the target from a browserslist setting or their own option. Since Vite 7, Vite's default build.target is 'baseline-widely-available': browser versions that support every Baseline Widely available feature as of a date fixed per major release (in Vite 8, Chrome 111, Firefox 114, and Safari 16.4). If an old Babel config still targets very old browsers, every modern visitor downloads and parses transforms and polyfills they never need.
The module/nomodule pattern
Around 2018 to 2021, teams had to serve both modern browsers and Internet Explorer 11. The module/nomodule pattern let them ship two builds and let each browser pick one, with no user-agent sniffing.
type="module" support.- A modern browser loads the
type="module"script and skips the one markednomodule, which means "skip this if you support modules". - An old browser ignores
type="module"as an unknown script type, and doesn't knownomoduleeither, so it runs the legacy script as a normal one. - Modern browsers got smaller, faster code; old ones still worked.
Today the pattern is mostly obsolete. Every browser in meaningful use has supported ES modules since about 2018 (Chrome 61, Safari 11, Firefox 60, Edge 16), and Microsoft retired Internet Explorer 11 in 2022. For most sites, the right build is a single modern one served with type="module".
Keep a legacy build only if your own analytics show real traffic from browsers without module support, such as some embedded TVs or kiosks. Vite's @vitejs/plugin-legacy still generates the nomodule build for that case.
Minification and compression
Two more steps shrink every chunk. Minification (esbuild, Terser, SWC, or Oxc) removes whitespace and comments, shortens local names, and drops dead branches like if (process.env.NODE_ENV !== "production"). Compression on the server or CDN (Brotli or gzip) then shrinks the bytes on the wire, usually by 3 to 5 times for JavaScript.
Keep the two apart. Compression cuts download time only; the engine parses the unzipped size. Minification and tree shaking cut the unzipped size, so they help both download and CPU cost. That's why the 1 second per MB rule counts unzipped bytes.
Check your understanding · Exercise 8
A team's bundle is 30% bigger than expected. Its Babel config still targets IE11, and analytics show no IE traffic for three years. What should they do? Pick one.
Bundle analysis: seeing what you ship
You can't shrink what you can't see. Most oversized bundles come not from one bad decision but from a dozen small surprises: a date library that brings every locale, a second copy of a utility package, an icon set imported whole, a server-only helper that leaked into the client. Bundle analysis finds them.
Reading a treemap
The main tool is a bundle analyzer, which draws a treemap: each chunk is a rectangle, split into smaller rectangles for every module inside it, with area proportional to bytes. Big rectangles are where the savings are. Most analyzers can show three sizes: stat (source before minifying), parsed (minified, what the engine reads), and gzip or Brotli (what travels over the network).
| Tool | Works with | What it shows |
|---|---|---|
rollup-plugin-visualizer | Vite, Rollup | Treemap, sunburst, or network graph of each chunk |
webpack-bundle-analyzer | webpack, Rspack | Interactive treemap with stat, parsed, and gzip sizes |
@next/bundle-analyzer | Next.js webpack builds | The webpack analyzer, with separate client and server reports |
source-map-explorer | Any build with source maps | Treemap from the final files, independent of the bundler |
| Coverage panel | Chrome DevTools | How many bytes of each loaded file actually ran |
| Lighthouse | Any page | "Reduce unused JavaScript" with estimated savings per file |
| Bundlephobia, pkg-size.dev | Any npm package | Size and tree-shakability before you install it |
A workflow that finds real savings
- Measure the initial chunk for your most important route. That's the number that drives LCP and start-up INP. Note its compressed and unzipped sizes.
- Open the treemap and sort by size. For each of the five biggest modules, ask: does the first screen need it? If not, it's a split candidate.
- Look for duplicates. The same package at two versions, or both
lodashandlodash-es, shows up as two similar rectangles. - Look for whole libraries used for one function. Locales, icon sets, and utility libraries are the usual suspects. Import the one piece, or replace the package.
- Check the Coverage panel on a real page load. If 60% of the initial JavaScript never runs during load, that's a strong sign of missing splits.
- Rebuild and compare. Keep the before and after numbers; it's the evidence for the change.
Budgets keep it from growing back
Bundles grow one small import at a time, and nobody notices a 3 KB change in review. A performance budget turns the size into a test: a CI step measures each important chunk and fails the build if it crosses a limit. Tools such as size-limit and Lighthouse CI work this way, and webpack's performance.maxEntrypointSize does too when performance.hints is set to "error".
[
{ "name": "initial JS, home", "path": "dist/assets/index-*.js", "limit": "170 KB" },
{ "name": "reports route", "path": "dist/assets/reports-*.js", "limit": "120 KB" }
]
.size-limit.json checked on every pull request. Limits are compressed sizes by default. A failing check forces the conversation before the bytes ship.In real products
Teams on a meta-framework like Next.js get route splitting without writing a single import(), so their analysis work is mostly about dependencies. In an older single-bundle app, admin and settings areas are usually the first split: the largest share of the code and the smallest share of the traffic.
Check your understanding · Exercise 9
The treemap for the home page's initial chunk shows moment with all its locales (about 75 KB compressed) used to format one date in the footer. Which fixes are reasonable? Select all that apply.
Using it in a system design interview
Code splitting comes up in most frontend system design interviews, usually in the performance part near the end. The interviewer isn't checking whether you know the word. They want to see you reason about cost: which code, for which users, at which moment, and what you'd trade.
When to bring it up
- When you list routes or views. "Each of these is its own chunk, so the feed doesn't pay for the settings page." One sentence, early, shows the habit.
- When a heavy feature appears. A rich text composer, a map, a video editor, a chart dashboard: say it loads on intent or visibility, and name the trigger.
- When you set non-functional requirements. A target like "interactive in under 3 seconds on a mid-range phone on 4G" turns into a JavaScript budget with the 1 second per MB rule.
- When asked how you'd make it faster. Measure first, then split, then trim dependencies, then protect caching across deploys.
What to draw
A small picture of chunks is enough: the initial chunk with the shell, the framework, and the first route; one box per other route; a few boxes for heavy features with their trigger written on the arrow ("on hover", "in view", "on click"); and a framework chunk with a note "cached for a year, content hash". Add sizes if you've estimated them.
Then say the budget out loud. "Initial JavaScript for the feed: about 150 KB compressed, roughly 500 KB unzipped, so about half a second of main-thread work on a mid-range phone. The composer is 120 KB and loads when the text box gets focus." Numbers like these turn a list of buzzwords into a design.
How deep to go, by level
| Level | What a strong answer includes |
|---|---|
| Junior | Route-based splitting, import() and React.lazy with Suspense, and why big bundles are slow on phones |
| Mid | Component splitting with triggers (interaction, visibility, idle prefetch), tree shaking and sideEffects, vendor chunks with content hashes, a bundle analyzer |
| Senior | A JavaScript budget derived from LCP and INP targets, waterfall risks and how to flatten them, chunk load errors on deploy, cache stability, budgets enforced in CI |
| Staff | Org-level guardrails: shared dependency policy, per-route budgets owned by teams, how micro-frontends or a design system affect duplication and chunking |
Think first
You're designing a social feed. Posts can contain images, videos, and polls; there's a composer with rich text and a GIF picker. How would you split the code?
Initial chunk: the app shell, the framework, the feed list, and the post renderer for text and images, since those are on the first screen for every visitor.
Video player and poll renderer: load on visibility, when a post of that type comes within a few hundred pixels of the viewport, so the cost arrives just before it's needed.
Composer: load on intent, when the user focuses the "What's new?" box or hovers the post button. The GIF picker loads only when its button is pressed.
Other routes (profile, settings, messages) each get their own chunk, prefetched when idle or when their links enter the viewport.
Guard rails: a CI size budget on the feed's initial chunk, an error boundary with a guarded reload for chunk load failures, and old assets kept for a few days after each deploy.
Check your understanding · Exercise 10
In an interview you say "I'll code split everything". The interviewer asks what could go wrong. Which answers are on point? Select all that apply.
Summary
- JavaScript costs download, parse, and execute; the last two run on the main thread at about 1 second per MB of unzipped code on a mid-range phone, and caching doesn't remove them.
- Static imports glue modules into the same chunk; dynamic
import()cuts the graph and starts a new chunk. - Split by route first, then split big, below-the-fold, or rarely used components, and start their load on intent, visibility, or idle time.
- Never split the first screen's content, and avoid nested lazy loads on the critical path: they create waterfalls.
- Tree shaking needs ES modules and honest
sideEffectsdeclarations; it removes unused exports, not rarely used ones. - Content-hashed chunks with a stable framework chunk let returning users re-download only what changed after a deploy.
- Module/nomodule is mostly obsolete: ship one modern build with an up-to-date target.
- Measure with an analyzer and the Coverage panel, then hold the gains with a size budget in CI.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Why is JavaScript more expensive than an image of the same size?
An image is downloaded and decoded, mostly off the main thread. JavaScript is downloaded, then parsed and compiled from its unzipped size, which is 3 to 4 times larger, then executed, and most of that happens on the main thread.
A useful rule: about 1 second of main-thread work per MB of unzipped JavaScript on a mid-range phone. While that runs, taps and typing wait, which hurts INP, and on client-rendered pages it delays LCP.
Caching removes the download on repeat visits but not the parse and execute, so shipping less code is the only full fix.
Question 2
Walk me through how React.lazy and Suspense load a component.
lazy(() => import("./Page")) gives React a component whose code isn't loaded yet. The bundler turns the import() into a separate chunk.
On first render, the lazy component starts the import and suspends. React shows the nearest Suspense fallback. When the promise resolves, React renders the real component; later renders use the cached module.
In practice: call lazy at module level, place boundaries so the layout stays visible, use transitions so navigations keep the old page instead of flashing a fallback, and add an error boundary for failed loads.
Question 3
What is tree shaking, and why does a library sometimes not shake?
Tree shaking drops exports that nothing imports. It relies on the static structure of ES modules, so the bundler can see what's used at build time.
It fails when the package ships CommonJS, when modules may have side effects and the package doesn't declare "sideEffects": false, when code uses dynamic access like lib[name], or when barrel files pull in modules with top-level effects.
Fixes: prefer ES module packages, import specific functions, mark side-effect-free code, and check the analyzer after changes.
Question 4
How do you make sure a deploy doesn't force users to re-download the whole app?
Content-hashed file names for every chunk, served with Cache-Control: public, max-age=31536000, immutable, and short caching for the HTML.
Framework and stable libraries in their own chunk so app changes don't change its hash; granular chunks so one library upgrade doesn't invalidate everything.
Deterministic module ids and the chunk map kept out of stable chunks. Verify by building twice with a one-line change and comparing file names.
Question 5
Can code splitting make a page slower?
Yes. Splitting code that the first screen needs adds a round trip and a parse before content appears. Nested lazy imports create a waterfall where each chunk discovers the next.
Too many tiny chunks add per-request overhead and compress worse. And users with long-lived tabs can hit missing chunks after a deploy if old files are deleted.
The fixes: split off the critical path, keep chunks above a minimum size, preload known dependencies with modulepreload, fetch data in parallel with code, keep old assets, and handle load errors.
Question 6
Is module/nomodule still needed?
It was a way to serve a modern build to browsers with ES module support and a legacy build to old browsers like IE11, using type="module" and the nomodule attribute.
Today it's mostly obsolete: every browser in meaningful use has supported modules since about 2018, and IE11 is retired. Ship one modern build.
Keep a legacy build only if your own analytics show real traffic that needs it, and make sure your build target and polyfills match the browsers you actually support.
Practice it
These design problems lean on code splitting and bundling.
- Design a product tourMedium
- Design an internationalization systemMedium
- Design an admin CMSMedium
- Design a plan to make a slow e-commerce site fastMedium
- Design a gamified learning app like DuolingoMedium
- Design an embeddable code editorHard
- Design LinkedInHard
- Design NotionHard
- Design an online code playground like CodePenHard
- Design Google MapsHard
- Design a web photo editorHard
- Design a micro-frontend shellHard
- Design a design system for many teamsHard
References
- import(): MDN The dynamic import syntax and its behaviour.
- lazy: React docs React.lazy, Suspense, and the common pitfalls.
- Reduce JavaScript payloads with code splitting: web.dev Route and component splitting with measurements.
- The cost of JavaScript in 2019: V8 blog Where parse, compile, and execute time goes.
- Tree shaking: webpack docs ES modules, the sideEffects field, and pure annotations.
- Code splitting: webpack docs Dynamic imports, SplitChunksPlugin, and prefetch hints.
- Build options: Vite docs Build targets, module preload, and chunking options.
- Don't cache HTTP errors in the module map: WHATWG HTML pull request The 2026 spec change about retrying failed module fetches.
Read next
- Back-of-the-envelope numbers for frontendQuick, rough math with round numbers (bytes over the network, CPU time on a cheap phone, memory in a long-lived tab, and requests per second at the server) that turns "this might be slow" into a reason to pick one design over another.
- Loading strategiesDeciding what the browser loads first, what it loads later, and what it loads only when needed, so the pixels that matter show up fast.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Performance budgets and monitoringAgreed limits on what a page may cost (kilobytes of JavaScript, LCP, INP) that CI checks on every pull request and real-user monitoring watches in production, so speed stops getting worse one small change at a time.
- HTTP cachingThe browser's built-in cache, steered by headers on each response, which lets a repeat visit skip the network entirely or ask only "has this changed?": the cheapest speed-up on the web.
- Prefetching and preloading dataStarting the fetch for the data and code of the next screen before the user asks for it, on hover, on sight, or on a guess, so the click feels instant without wasting the user's data plan.