cf.completefrontend
My progress
FUNDAMENTALS · MODULE 8

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.

  1. 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.
  2. 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.
  3. 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.

Two timelines on slow 4G and a mid-range phone. A 450 KB image downloads in about 2.4 seconds, then decodes in a few tens of milliseconds, off the main thread. 450 KB of JavaScript also downloads in about 2.4 seconds, but then needs about 1.6 seconds of main-thread work to parse, compile, and run its 1.6 MB of unzipped code, during which taps wait.450 KB imagedownload 2.4 sdecode, off the main thread450 KB JavaScript, 1.6 MB unzippeddownload 2.4 smain thread busy 1.6 s0 s1 s2 s3 s4 s
Same bytes on the wire, very different bills. The image is nearly done when it arrives; the JavaScript has just started, and the hatched part blocks input.

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.

Device:
Network:
Visit:
450 KB of compressed JavaScript, about 1575 KB unzipped, on a Mid-range phone over Slow 4G, first visit: about 2.4 s to download and 1.6 s of main-thread work to parse, compile, and run. Total about 4.0 s.450 KB on the wire, about 1575 KB for the engine to parsedownloadmain threadtaps wait for about 1.6 s0 s1 s2 s3 s4 s
Rough estimates using about 3.5x for unzipping and about 1 second per MB of unzipped code on a mid-range phone. Switch to a repeat visit: the download disappears, the main-thread part does not.

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.

A module graph. main imports App, which statically imports Header and money; those four modules form the initial chunk. App dynamically imports Reports and Admin, and each becomes its own async chunk. Both Reports and Admin import charts, so charts is pulled out into one shared chunk that loads with either of them.initial chunkReports chunkAdmin chunksharedmainAppHeadermoneyReportsAdminchartsplain arrows: static import, glued. warm arrows: import(), cut.
Static imports glue modules into the same chunk. Each 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).

The vocabulary of the rest of the lesson.
TermWhat it meansExample
ModuleOne source file with imports and exportssrc/pages/Reports.tsx
EntryWhere the bundler starts walking the graphsrc/main.tsx
ChunkOne output file holding one or more modulesassets/Reports-B3k9xQ.js
Initial chunkCode every page load needs before the app runsReact, router, layout, home page
Async chunkCode loaded later by a dynamic import()Reports page, chart library
Shared chunkModules used by several async chunks, pulled out onceChart 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.

ts
// 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;
  }
});
The exporter and its 180 KB PDF library now load only for users who press Export.

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.

  1. The click handler reaches import("./pdf/exportPdf"). At build time the bundler replaced that path with the real file name, such as assets/exportPdf-Dq81aZ.js, and the list of chunks it depends on.
  2. The bundler's small runtime requests the chunk and its dependencies in parallel: Vite adds link rel="modulepreload" tags for the dependencies, and webpack adds script tags.
  3. The CDN answers. With a warm connection, 60 KB compressed arrives in about 100 to 300 ms.
  4. 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.
  5. The promise resolves with the module's exports. Your code calls exportPdf. On the next click the module is already in memory and import() resolves almost at once.
A sequence diagram with four lifelines: user, app code, bundler runtime, and CDN. The user clicks Export. The app calls import of the PDF exporter. The runtime requests the exporter chunk and its dependencies from the CDN in parallel. The CDN returns about 60 KB in about 200 milliseconds. The runtime parses and runs the modules, about 200 milliseconds on a mid-range phone, and resolves the promise with the module's exports. The app calls exportPdf and the PDF downloads.UserApp codeBundler runtimeCDNclick Exportimport(exportPdf)GET chunk + deps60 KB, ~200 msexports, ~200 ms runclick to ready: about 0.4 to 0.5 s on 4G and a mid-range phone
Every step after the click is time the user waits. The next chapter is about moving the request earlier, to hover, focus, or idle time. A second 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.

tsx
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>
  );
}
Suspense handles the waiting; an error boundary handles a chunk that fails to load.
  • 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.

tsx
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") },
]);
Each route module exports its 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.

Split out:
The expense app's initial chunk: the 140 KB shell and framework plus every feature not split out, 450 KB compressed in total, about 1.6 s of main-thread work on a mid-range phone.initial chunk: 450 KB compressedshelllistscanreportsadminnotesabout 1.6 s of main-thread work on a mid-range phonethe first screen's code stays in the initial chunk
Green is code the first screen needs; red is code it doesn't. Split the red parts. Splitting the expense list, the first screen itself, saves bytes but makes the main content wait for a second request.

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?

On interaction (click)

Call import() inside the click or keydown handler

Show a spinner or disable the button until it resolves

Pros

  • Zero bytes for users who never click
  • Simplest to write

Cons

  • The full fetch, parse, and run delay lands after the click
  • A slow network makes the feature feel broken

On intent (hover, focus, touchstart)

Start import() on pointerenter or focus of the trigger

Reuse the same promise on click

Pros

  • Hides 100 to 300 ms of the wait for mouse users
  • Still free for users who never come near

Cons

  • Touch screens have no hover, so the gain is smaller there
  • Some wasted downloads on hovers that don't lead to clicks

On visibility

An IntersectionObserver with a rootMargin of a few hundred pixels

Import when the placeholder nears the viewport

Pros

  • Fits below-the-fold widgets: comments, maps, carousels
  • Ready by the time the user scrolls there

Cons

  • Fast scrollers can still see the placeholder
  • Needs a fixed-size placeholder to avoid layout shift

When idle (prefetch)

After load, requestIdleCallback or link rel="prefetch"

Frameworks prefetch links in the viewport

Pros

  • The next page or feature is usually cached before it's needed
  • Doesn't compete with the first load if done after it

Cons

  • Spends data on code that may never run
  • Should respect Save-Data and slow connections

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.

ts
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.
Two timelines for opening a rich text editor, with the pointer entering the button at 0 milliseconds and the click at 400 milliseconds. Loading on click: the fetch runs from 400 to 750 milliseconds and the parse from 750 to 950, so the user waits 550 milliseconds after clicking. Loading on hover: the fetch starts at 0 and the parse ends at 550 milliseconds, so the user waits only 150 milliseconds after clicking.hoverclickload on clickuser waits 550 msload on hoveruser waits 150 ms0 ms250 ms500 ms750 ms1000 ms
The time between hover and click is free time. Starting the import on intent spends it on the download, so most of the wait is gone by the time the user commits.

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.

Before the build, utils.ts exports four functions: format, debounce, parseCsv, and deepClone. The app imports only format and debounce. After the build, the bundle contains only format and debounce; parseCsv and deepClone were never imported and are dropped.before: utils.tsformat()debounce()parseCsv()deepClone()buildafter: in the bundleformat()debounce()never imported,so dropped:parseCsv, deepCloneapp.ts: import { format, debounce } from "./utils"
ES module imports are static, so the bundler sees at build time that nothing uses parseCsv or deepClone. A require() of the same file would keep all four.
ts
// 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);
Same function, two very different bills. Prefer packages that publish ES modules.

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": false while 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 if theme is 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 of utils.

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.

Chunks:
Deploy:
App + one vendor chunk: app.js 90 KB gets a new hash, vendor.js 380 KB keeps its hash. After "Edit one line of app code", a returning visitor to the home page downloads 90 KB again. A first visit to the home page costs 470 KB.after the deploy, each chunk the home page loadsapp.js90 KBnew hashvendor.js380 KBcachedreturning user downloads again: 90 KBfirst visit to the home page: 470 KB
Each chunk's name is a hash of its content. Only chunks whose content changed get new names and are downloaded again; the rest stay fresh in the cache for a year.

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.

ts
// 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
        },
      },
    },
  },
});
One targeted group. A single group for all of 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.

Two waterfalls on a 150 millisecond round trip. Nested: index.js downloads and runs, then requests Reports.js, which downloads and runs, then requests charts.js; the chart appears at about 1.15 seconds. Preloaded: all three files are requested at the start with modulepreload, they download in parallel, and they run one after another; the chart appears at about 0.6 seconds.nested: each chunk found by the one beforechart at 1.15 sindex.jsReports.jscharts.jspreloaded: all three requested at oncechart at 0.6 sindex.jsReports.jscharts.js0 s0.3 s0.6 s0.9 s1.2 s
The same three files, the same bytes. Nesting turns parallel downloads into a chain of round trips. A 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 a modulepreload link in the HTML, so the fetch starts before index.js even 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:preloadError event on window when a dynamic import fails; webpack throws a ChunkLoadError. 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=1 on the chunk URL, avoids it.

Other things that break

ProblemHow you noticeFix
Spinners flash on every navigationUsers see a skeleton for 50 ms on fast connectionsUse transitions so the old page stays visible; prefetch likely routes
Two copies of a libraryThe analyzer shows the same package twice at different versionsAlign versions with npm dedupe or package manager overrides
A split that saves nothingThe lazy module is also imported statically elsewhereRemove the static import; Vite warns about this
Layout shift when a lazy widget appearsCLS rises on pages with lazy widgetsReserve the widget's space with a fixed-size placeholder
Initial chunk slowly grows backBundle size creeps up release after releaseA 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.

Two script tags from a page of about 2019. The first has type module and loads modern.js: modern browsers run it, and IE11 skips it as an unknown type. The second has the nomodule attribute and loads legacy.js: modern browsers skip it because they support modules, and IE11 runs it as a normal script. Today every browser in use supports modules, so one modern build is enough.the HTML, about 2019<script type="module" src="modern.js">modern: runs itIE11: skips it<script nomodule src="legacy.js">modern: skips itIE11: runs it2026: every browser in use runs modules; ship one modern build
Each browser picks exactly one build with no user-agent sniffing. The trick is mostly history now: the legacy half only matters if your analytics still show browsers without type="module" support.
  • A modern browser loads the type="module" script and skips the one marked nomodule, which means "skip this if you support modules".
  • An old browser ignores type="module" as an unknown script type, and doesn't know nomodule either, 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).

A treemap of a 450 KB initial chunk, with areas proportional to compressed bytes. The framework (React, React DOM, the router) at 140 KB and app code at 90 KB are expected. A chart library of 80 KB is used only on the reports route, so it is a split candidate. Moment with all locales at 75 KB, a full icon set of 1,500 icons at 40 KB, and CommonJS lodash at 25 KB are all replaceable or tree-shakable.initial chunk, 450 KB compressed: area is bytesframework 140 KBapp 90 KBcharts 80 KB, /reportsmoment 75 KB, all localesicons 40 KB, all 1,500lodash 25 KB, CommonJSgreen: needed. warm: split it. red: replace it or import less.
Read a treemap by area: the biggest boxes are where the savings are. Here most of the 220 KB in warm and red boxes can leave the initial chunk without removing a single feature.
ToolWorks withWhat it shows
rollup-plugin-visualizerVite, RollupTreemap, sunburst, or network graph of each chunk
webpack-bundle-analyzerwebpack, RspackInteractive treemap with stat, parsed, and gzip sizes
@next/bundle-analyzerNext.js webpack buildsThe webpack analyzer, with separate client and server reports
source-map-explorerAny build with source mapsTreemap from the final files, independent of the bundler
Coverage panelChrome DevToolsHow many bytes of each loaded file actually ran
LighthouseAny page"Reduce unused JavaScript" with estimated savings per file
Bundlephobia, pkg-size.devAny npm packageSize and tree-shakability before you install it

A workflow that finds real savings

  1. 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.
  2. 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.
  3. Look for duplicates. The same package at two versions, or both lodash and lodash-es, shows up as two similar rectangles.
  4. 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.
  5. 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.
  6. 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".

json
[
  { "name": "initial JS, home", "path": "dist/assets/index-*.js", "limit": "170 KB" },
  { "name": "reports route", "path": "dist/assets/reports-*.js", "limit": "120 KB" }
]
A .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.
A decision flowchart. Does the first screen need this code? If yes, keep it in the initial chunk. If no, is it bigger than about 20 KB compressed? If no, keep it, because a separate request costs more than it saves. If yes, will most users need it soon? If yes, split it and prefetch it when idle or when its link comes into view. If no, split it and load it on intent or when it becomes visible.First screen needs it?yesInitial chunkstatic importnoOver ~20 KB?noKeep ita request costs moreyesMost users need it soon?yesSplit + prefetchwhen idle or link in viewnoSplit, load lateon intent or visibility
Three questions cover most cases. The first one protects LCP; the second avoids tiny chunks; the third picks the trigger.

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

LevelWhat a strong answer includes
JuniorRoute-based splitting, import() and React.lazy with Suspense, and why big bundles are slow on phones
MidComponent splitting with triggers (interaction, visibility, idle prefetch), tree shaking and sideEffects, vendor chunks with content hashes, a bundle analyzer
SeniorA 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
StaffOrg-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?

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 sideEffects declarations; 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?

Question 2

Walk me through how React.lazy and Suspense load a component.

Question 3

What is tree shaking, and why does a library sometimes not shake?

Question 4

How do you make sure a deploy doesn't force users to re-download the whole app?

Question 5

Can code splitting make a page slower?

Question 6

Is module/nomodule still needed?

Practice it

These design problems lean on code splitting and bundling.

References

Read next