What you'll learn: By the end of this lesson you will be able to explain the difference between code splitting, lazy loading and tree shaking, read an application as a module graph, predict which exports and modules a bundler can remove and which chunks it creates, say what the
sideEffectsflag promises and how a wrong one breaks a page, choose where to split, handle a lazy chunk that fails to load, and test your predictions with a small analyser.
Think of moving house. You could unload every box on the first day, including the winter coats in July. Or you could unpack the kitchen first, leave the rest in the garage and fetch a box when you need it, after throwing away what you know you will never use. A web page has the same choice with JavaScript: every byte downloaded, parsed and run before the first screen competes with what the visitor came to see.
Three ideas share this job. Tree shaking decides what to throw away, code splitting how to cut the rest into files, lazy loading when each file is fetched. One small storefront is the running example.
The core idea
- A bundler works on a graph, not on files. Each module names the modules it imports; from an entry point the bundler builds a module graph, and everything below is a computation over it.
- Static imports are fixed at build time;
import()is the exit. A dynamicimport()is a run-time request that returns a promise, and it is the only place a build can cut the graph into pieces that load later. - Code may be removed only when the bundler can prove it is unused. The proof comes from the static shape of
importandexport, and from one question the bundler cannot answer alone: does importing this module do something beyond providing values?
In short: tree shaking removes exports and modules that nothing uses; code splitting cuts the output into several files; lazy loading fetches one of them only when needed. Tree shaking makes the total smaller; splitting and lazy loading change what loads first, and the total can even grow a little. A lab models these ideas in a few hundred lines of TypeScript. It is not a bundler: it reads no files, parses no JavaScript, and its sizes are abstract units.
The module graph
A module is a file with its own scope that can export values and import values from other modules. MDN states that import declarations "can only be present in modules, and only at the top-level (i.e., not inside blocks, functions, etc.)", and that they are hoisted, so the imported modules' side effects "are produced before the rest of the module's code runs". So a tool can read the whole graph without running anything. Webpack's guide says tree shaking "relies on the static structure of ES2015 module syntax, i.e. import and export", and warns that no compiler should turn that syntax into CommonJS modules (the default of the Babel preset @babel/preset-env does).
In the storefront, the entry main imports a router, a UI library and analytics. The router opens three pages (product, checkout, admin) and the admin page opens a charting module. The UI library is a barrel: a module that only re-exports components from other modules (button, header, modal, datepicker, tooltip) so callers write one import line.
Figure 1. The storefront as a module graph, cut into what loads first and what loads on demand
How to read this: only the entry chunk is needed for the first screen. Each dashed arrow is a dynamic import(), a point where the graph is cut. The shared chunk exists because two pages need the same module, so the bundler gives it a file of its own rather than copying it into both.
Tree shaking: deciding what to throw away
Tree shaking starts from what must run. The lab's rules:
- An entry module always runs, so whatever its top-level code uses is live.
- A live export keeps its module in the output and makes whatever that export's code needs live too.
- A module imported statically but with no live export is kept only if the bundler must assume importing it has side effects.
Rule 3 is the subtle one. Webpack's guide defines a side effect as "code that performs a special behavior when imported, other than exposing one or more exports", and gives polyfills as the example. Registering a global listener, patching a built-in or injecting a stylesheet all happen at import time, so dropping such a module would change the program.
Two different mechanisms
Webpack's guide separates two optimisations. usedExports finds individual unused exports and relies on a minifier to spot side effects "in statements", which it calls "a difficult task in JavaScript". sideEffects is a declaration: "If no direct export from a module flagged with no-sideEffects is used, the bundler can skip evaluating the module for side effects." The guide calls it "much more effective" because a skipped module's own imports need not be evaluated either.
You state the flag yourself: in webpack the "sideEffects" field of package.json (false, or a list of files that do have effects); in Rollup the treeshake.moduleSideEffects option, documented default true, which when false assumes "modules and external dependencies from which nothing is imported do not have other side effects". The shared idea is the safe default: unless you say otherwise, an imported module is assumed to do something.
Figure 2. Which parts of a barrel survive, and why
How to read this: the dashed box is the only module the build can leave out, because nothing uses it and its flag says importing it does nothing. Without that flag the build must keep it.
Illustration 1: the minimal example
// math.js
export function square(x) { return x * x; }
export function cube(x) { return x * x * x; }
// main.js
import { square } from "./math.js";
console.log(square(4));
A tree-shaking build can leave cube out, because nothing uses it and declaring a function has no effect. Had math.js contained a top-level window.__mathLoaded = true, the module would have to stay even with no export used, unless a flag declared it pure.
Code splitting: deciding how to cut the output
Webpack's guide names three general approaches: entry points (each its own bundle), preventing duplication (so a module two entries need is not copied into both) and dynamic imports (cutting at import() calls). It names the pitfall of the first: "If there are any duplicated modules between entry chunks they will be included in both bundles."
The lab implements a grouping rule in the spirit of most bundlers, without claiming to match any one tool:
- The entry points and the targets of used
import()calls are roots. - Each surviving module goes into the chunk for the set of roots that need it. Needed by one root, it joins that root's chunk. Needed by several, it goes into a shared chunk named for that set (for example
shared:pages/checkout+pages/product). - A root is removed from that set when every place that can call
import()for it has already loaded the module. A module used bymainand by a page it opens stays inmain's chunk: it is already downloaded when the page loads.
Real bundlers add more: size thresholds (Rollup documents output.experimentalMinChunkSize as "a minimal chunk size target in Byte for code-splitting setups"), manual chunks (output.manualChunks "allows the creation of custom shared common chunks") and content hashes. At the time of writing, Vite's current documentation names Rolldown as the bundler underneath, configured through build.rolldownOptions; earlier majors used Rollup. Check the docs of your version before copying a chunking option from a blog post.
What import() is
MDN describes dynamic import as a function-like expression "that allows loading an ECMAScript module asynchronously and dynamically", and notes that it is "available in both scripts and modules". It returns a promise that fulfils to the module namespace object, and the module is evaluated once: "a piece of JavaScript code is never executed more than once, even if it is imported multiple times". It is the right tool when a static import "significantly slows the loading of your code" and there is "a low likelihood that you will need the code you are importing". And MDN warns: "Use dynamic import only when necessary. The static form is preferable for loading initial dependencies, and can benefit more readily from static analysis tools and tree shaking."
Figure 3. A lazy chunk loading on demand
How to read this: the wait between the click and the render is the price of laziness. It is paid once per chunk per visit, and everything the visitor never clicks is never fetched.
Illustration 2: the realistic example, a storefront
The lab models the storefront as a graph of 15 modules with illustrative sizes. Build 3 in the output below (flags honoured) puts main, the router, analytics, the header and the UI barrel into an entry chunk of size 83, and the pages, the charting module and a small shared chunk into chunks that load later. A first visit fetches 83; opening checkout fetches 30 more; an admin who opens the admin page and then a chart fetches 40 and then 60.
Now the barrel's cost. In build 3 the date picker, which only checkout needs, still lands in the entry chunk: the barrel statically imports it, it is in the output because checkout uses it through the barrel, so whoever loads the barrel loads the date picker. In build 4 the entry and the pages import each component's own module. The total stays 233, but the entry chunk falls from 83 to 23 and the date picker moves into the checkout chunk. That is a result of this lab's rules; whether a given bundler lays out a barrel the same way depends on the tool and version, so measure your own build with its analyser before restructuring imports.
Lazy loading in the framework
Frameworks wrap import() so that the loading state fits the component model. React's lazy "lets you defer loading component's code until it is rendered for the first time", and its load function must return a promise (or other thenable) that resolves to an object whose .default is a valid component. It is declared at the top level of a module, never inside a component, and wrapped in <Suspense> to show a fallback: "If MarkdownPreview hasn't loaded yet, Loading will be shown in its place."
import { lazy, Suspense } from "react";
// Declared at module top level, outside any component.
const CheckoutPage = lazy(() => import("./pages/CheckoutPage.js"));
export function Routes() {
return (
<Suspense fallback={<p>Loading checkout…</p>}>
<CheckoutPage />
</Suspense>
);
}
The React documentation also states what happens when the load fails: "If the Promise rejects, React will throw the rejection reason for the nearest Error Boundary to handle." A lazy component with no error boundary above it is a page that can go blank. Other frameworks and routers offer their own wrappers around import(); check how yours reports a failed load.
Figure 4. The states of a lazy chunk
How to read this: most tutorials draw only the top path. The Failed branch is the one that costs you users, because a lazy chunk is a network request that can fail while the page around it looks fine.
When to split, and what to split
Splitting is not free: each chunk is a request (cheaper over HTTP/2 and later, see HTTP protocols), a loading state and a place where the network can fail. The deciding question is: does the first screen need this code?
Figure 5. Where to put a split
import() in the router"] C -->|"no"| E{"Is it large and behind a
user action? (editor, chart, map)"} E -->|"yes"| F["Split the component,
hint it on hover or idle"] E -->|"no"| G{"Is it small?"} G -->|"yes"| B G -->|"no"| H["Split a rarely used feature
or a locale, then measure"]
How to read this: split along the lines users already choose (routes) first. Tiny pieces are not worth a request. Web.dev calls splitting "on the route or component level" the simpler approach with a client-side framework.
Hiding the wait: preload and prefetch
A lazy chunk downloads only when the code asks, so the click pays the full round trip. To start earlier, webpack documents magic comments that emit resource hints: import(/* webpackPrefetch: true */ "./LoginModal.js") and import(/* webpackPreload: true */ "ChartingLibrary"). In plain HTML, MDN describes rel="modulepreload" as "a declarative way to preemptively fetch a module script, parse and compile it, and store it in the document's module map for later execution", and adds that fetching the dependencies of a module "is a browser-specific optimization", so listing each one is the portable approach. Hint what is likely next, not everything.
Trade-offs
| Technique | What it buys | What it costs | Watch for |
|---|---|---|---|
| Tree shaking | Smaller total output | Needs static import/export; trusts your flags |
A wrong sideEffects: false drops code that does something |
| Route splitting | Small entry, pages fetched on demand | A wait per first navigation; a loading and error state | Shared code duplicated or fragmented into many tiny chunks |
| Component splitting | Heavy widgets leave the entry | More chunks, more states | Layout shift when the component appears (see CLS) |
| Preload and prefetch | Hides the wait | Bandwidth spent on code that may go unused | Hints for everything compete with what the page needs now |
| Barrel files | One short import line | Can pull unrelated code into a chunk in a build that cannot separate it | Measure the chunk layout, not the import line |
Build it: a module graph analyser
Goal. Describe a module graph as data, run it through the analyser, and read off what survives, how it is chunked and what each visit downloads. Then change a flag or an import and predict the effect before you run it.
This is a model, not a bundler. It reads no files and parses no JavaScript, and knows nothing about minification, CSS, content hashes or any bundler's real heuristics. Its sizes are abstract units, so it cannot tell you how big your bundle is. It shows the reasoning a bundler performs.
Setup. The lab lives in the course's example workspace. It needs Node.js (the TypeScript files run directly on Node 26.12.1, where it was captured) and pnpm.
cd examples/frontend
pnpm install
pnpm --filter ./labs/module-graph-analyser test # 28 tests
cd labs/module-graph-analyser
node src/demo.ts # prints the eight scenarios below
Steps.
- Compare scenarios 1 and 3. Find the module scenario 3 removes and the exports it removes from
chartsandlib/dates. - Compare scenarios 2 and 3. Scenario 2 treats every module as possibly effectful, so
ui/tooltipstays, with its setup code (3 units) but not its unused export. - Compare scenarios 3 and 4. The total is the same, the entry chunk is not: find where the date picker lands in each.
- Read scenarios 5 and 6: each
openandthenline is what a step fetches. - Read scenarios 7 and 8. When the polyfill is wrongly declared pure, the output shrinks and the last line says
LOST EFFECT. The analyser can say so only because the graph records the polyfill's real effect in a separate field; a real bundler has no such field and stays silent.
Expected output, captured from a real run (Node 26.12.1, 2026-11-08):
=== 1. Everything reachable is bundled (no tree shaking)
options: treeShake=false honourSideEffectsFlag=true keepEntryExports=false
modules in the output: 15, removed: 0, total size: 297
chunk entry:main size 107 modules: analytics, lib/dates, main, router, ui, ui/button, ui/datepicker, ui/header, ui/modal, ui/tooltip
chunk shared:pages/checkout+pages/product size 5 modules: lib/format
chunk async:charts size 100 modules: charts
chunk async:pages/admin size 40 modules: pages/admin
chunk async:pages/checkout size 25 modules: pages/checkout
chunk async:pages/product size 20 modules: pages/product
=== 2. Tree shaking, side-effect flags ignored (every module assumed effectful)
options: treeShake=true honourSideEffectsFlag=false keepEntryExports=false
modules in the output: 15, removed: 0, total size: 236
chunk entry:main size 86 modules: analytics, lib/dates, main, router, ui, ui/button, ui/datepicker, ui/header, ui/modal, ui/tooltip
chunk shared:pages/checkout+pages/product size 5 modules: lib/format
chunk async:charts size 60 modules: charts
chunk async:pages/admin size 40 modules: pages/admin
chunk async:pages/checkout size 25 modules: pages/checkout
chunk async:pages/product size 20 modules: pages/product
unused exports removed from charts: drawPie
unused exports removed from lib/dates: diff
unused exports removed from ui: Tooltip
unused exports removed from ui/tooltip: Tooltip
=== 3. Tree shaking, side-effect flags honoured
options: treeShake=true honourSideEffectsFlag=true keepEntryExports=false
modules in the output: 14, removed: 1, total size: 233
chunk entry:main size 83 modules: analytics, lib/dates, main, router, ui, ui/button, ui/datepicker, ui/header, ui/modal
chunk shared:pages/checkout+pages/product size 5 modules: lib/format
chunk async:charts size 60 modules: charts
chunk async:pages/admin size 40 modules: pages/admin
chunk async:pages/checkout size 25 modules: pages/checkout
chunk async:pages/product size 20 modules: pages/product
removed modules: ui/tooltip
unused exports removed from charts: drawPie
unused exports removed from lib/dates: diff
unused exports removed from ui: Tooltip
=== 4. The same, but the entry and pages import components directly (no barrel)
options: treeShake=true honourSideEffectsFlag=true keepEntryExports=false
modules in the output: 13, removed: 0, total size: 233
chunk entry:main size 23 modules: analytics, main, router, ui/button, ui/header
chunk shared:charts+pages/checkout+pages/product size 22 modules: lib/dates
chunk shared:pages/admin+pages/product size 8 modules: ui/modal
chunk shared:pages/checkout+pages/product size 5 modules: lib/format
chunk async:charts size 60 modules: charts
chunk async:pages/admin size 40 modules: pages/admin
chunk async:pages/checkout size 55 modules: pages/checkout, ui/datepicker
chunk async:pages/product size 20 modules: pages/product
unused exports removed from charts: drawPie
unused exports removed from lib/dates: diff
=== 5. Visits to build 3
--- the shopper opens the site
open main fetch 83 total 83 entry:main
--- then opens the checkout page
open main fetch 83 total 83 entry:main
then pages/checkout fetch 30 total 113 shared:pages/checkout+pages/product, async:pages/checkout
--- or the admin opens the admin page and then the chart
open main fetch 83 total 83 entry:main
then pages/admin fetch 40 total 123 async:pages/admin
then charts fetch 60 total 183 async:charts
=== 6. The same visits to build 4
--- the shopper opens the site
open main fetch 23 total 23 entry:main
--- then opens the checkout page
open main fetch 23 total 23 entry:main
then pages/checkout fetch 82 total 105 shared:charts+pages/checkout+pages/product, shared:pages/checkout+pages/product, async:pages/checkout
=== 7. A polyfill imported only for its effect, with no flag
options: treeShake=true honourSideEffectsFlag=true keepEntryExports=false
modules in the output: 15, removed: 1, total size: 240
chunk entry:main size 90 modules: analytics, lib/dates, main, polyfill/intl, router, ui, ui/button, ui/datepicker, ui/header, ui/modal
chunk shared:pages/checkout+pages/product size 5 modules: lib/format
chunk async:charts size 60 modules: charts
chunk async:pages/admin size 40 modules: pages/admin
chunk async:pages/checkout size 25 modules: pages/checkout
chunk async:pages/product size 20 modules: pages/product
removed modules: ui/tooltip
unused exports removed from charts: drawPie
unused exports removed from lib/dates: diff
unused exports removed from ui: Tooltip
=== 8. The same polyfill wrongly flagged sideEffects: false
options: treeShake=true honourSideEffectsFlag=true keepEntryExports=false
modules in the output: 14, removed: 2, total size: 233
chunk entry:main size 83 modules: analytics, lib/dates, main, router, ui, ui/button, ui/datepicker, ui/header, ui/modal
chunk shared:pages/checkout+pages/product size 5 modules: lib/format
chunk async:charts size 60 modules: charts
chunk async:pages/admin size 40 modules: pages/admin
chunk async:pages/checkout size 25 modules: pages/checkout
chunk async:pages/product size 20 modules: pages/product
removed modules: polyfill/intl, ui/tooltip
unused exports removed from charts: drawPie
unused exports removed from lib/dates: diff
unused exports removed from ui: Tooltip
LOST EFFECT: polyfill/intl was removed, but it has a top-level effect
Break it. In src/analyse.ts, change (m.sideEffects ?? true) in mustKeepForEffects to (m.sideEffects ?? false), so a missing flag is treated as "pure". Two tests fail: an imported module with no flag must be kept, and so must the top-level code it runs. Revert it. Dropping a module that does something is a bug; keeping one that does nothing only wastes bytes.
Stretch. Pick one:
- Add a
manualChunks-style option that forces named modules into one chunk, and find a graph where a visit fetches more than before. - Model a minimum chunk size: merge a shared chunk below N units into the chunk of one of its roots, and compare the number of requests with the bytes.
In practice: a storefront whose admin tools ship to every shopper
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.
The situation
A storefront ships one JavaScript file for the shopper pages, the checkout and an internal admin dashboard that staff open once a day. Shoppers on mid-range phones see a slow first screen and a long task while the page parses code they will never run. Field data (illustrative) shows well under one percent of visitors ever open the admin page. Three engineers own the front end.
Starting architecture
Figure 6. One bundle for everybody
How to read this: both boxes are paid by every visitor, whether or not they will ever open the admin page.
Options considered
| Option | What it fixes | What it costs | Risk |
|---|---|---|---|
| Split by route | Admin and checkout leave the entry | A loading state per route | A failed chunk is a failed navigation |
| Split only the charting library | Removes the heaviest piece | Smaller win | Admin code still ships to shoppers |
The decision
| Field | Decision record |
|---|---|
| Context | The entry includes admin pages and a charting module that almost no visitor uses. |
| Decision | Split by route with import(), put the charting module behind the admin page's own import(), import components from their own modules instead of the barrel, wrap every lazy route in an error boundary with a reload action, and set sideEffects honestly. |
| Consequences, good | Smaller entry for shoppers; admin cost is paid by admins only. |
| Consequences, bad | A loading state on first navigation to each page; more failure modes. |
| Revisit when | Field data shows slow navigations to checkout, in which case prefetch that chunk on idle. |
Rollout and verification
- Predict first: write down which chunk each module should land in, then compare with the bundler's analysis report. A module in the wrong chunk is an unexpected static import.
- Ship behind a flag to a share of traffic and compare field metrics.
- Test the failure path: block a chunk request in the browser's developer tools and confirm the error boundary offers a reload.
- Check effects: confirm the analytics listener and the polyfills still run with the new flags.
The 2 a.m. failure
After a night release, support reports that "the checkout button does nothing". The cause is not the new code. Visitors who loaded the site before the release still hold the old entry chunk, which asks for page chunks by their old hashed names, and the deploy replaced the whole asset folder. Each import() rejects, React's lazy throws the rejection to the nearest error boundary, and the only boundary is at the app root, which shows a blank screen.
First three checks: (1) the network panel of an affected session, for a failed chunk request; (2) whether the failing file name matches an earlier release; (3) whether an error boundary exists below the router. Mitigation: put the previous release's assets back next to the new ones. Follow-up: keep the previous release's assets for a defined period, reload once on failure (Vite documents a vite:preloadError event, emitted "when it fails to load dynamic imports", where event.preventDefault() stops the error being thrown), and add a retrying boundary around each lazy route.
Illustration 3: when the flag lies
Scenarios 7 and 8 show the other classic failure. A polyfill that adds a missing method to a global is imported only for that effect, and with no flag the bundler keeps it. Someone adds "sideEffects": false "to get a smaller bundle", the build drops the polyfill, and the page breaks in exactly the browsers that needed it, with no build error. Webpack's guide lists polyfills and global listeners as hidden side effects, and notes that a CSS file imported only for its effect needs to be on the side-effect list "so it will not be unintentionally dropped in production mode".
Architect's notes
- Split along routes first: the lowest-risk cut, and one users already understand as a navigation.
- Keep the entry chunk a budget and check it in CI; it grows one import at a time.
- Treat
sideEffectsas a contract. Declare it per package, list the files that do have effects, and add a test that loads the site's polyfills and global listeners. - Look at the chunk graph, not the import line. Whether a barrel enlarges the output depends on your bundler. Measure.
- Design the failed state of every lazy boundary and keep the previous release's assets available after a deploy.
Common mistakes
- Using
import()for the first screen. Anything needed at start belongs in the entry; the static form is preferable there. - Splitting into many tiny chunks: each adds a request and a failure mode for almost no saving.
- Setting
sideEffects: falsefor the whole package when some files register listeners, patch globals or import CSS. - Converting module syntax to CommonJS in a compiler step, which removes the static shape tree shaking needs.
- Declaring
lazycomponents inside other components: React's documentation says to declare them at module top level. - Having no error boundary around lazy routes, so a failed chunk request blanks the page.
- Deleting old hashed assets at deploy time while visitors still hold the old entry chunk.
Key takeaways
- Tree shaking removes unused exports and modules; code splitting cuts the survivors into chunks; lazy loading decides when a chunk is fetched.
- A module imported but unused is kept unless the bundler is told, with
sideEffects(webpack) ormoduleSideEffects(Rollup), that importing it does nothing. A false claim drops real behaviour silently. import()is the cut point: it returns a promise, evaluates the module once, and is best used for what the first screen does not need.- A module goes into the chunk for the set of roots that need it; several roots mean a shared chunk.
- Every lazy chunk is a network request: design a loading state, an error state and a deploy story that keeps old chunks reachable.
- Preload hints hide the wait for what is likely next; too many cost the bandwidth splitting saved.
Practice exercises
Exercise 1 — Starter (30 min): In the lab, add a new module lib/markdown (size 20, sideEffects: false) used only by pages/product. Predict which chunk it lands in and the new entry size, then run the demo and check.
Exercise 2 — Intermediate (2 hours): In a small app that uses a barrel file, read your bundler's analysis report, import a one-page component directly instead, and compare the two reports. State what one app cannot prove.
Exercise 3 — Advanced (half day): Run the failure drill: build twice with different content hashes, serve the old entry with only the new assets, click a lazy route, and record what the page shows with and without an error boundary.
Related reading
In this course:
- The Critical Rendering Path, what blocks the first render and why script size matters.
- The Event Loop and the Main Thread, why parsing and running a large script becomes a long task.
- HTTP caching, why hashed file names make chunks cacheable and what a deploy does to them.
- Loading Performance and Core Web Vitals, how the effect shows up in field metrics.
Official documentation
- MDN: import(), semantics, caching, and when to use it.
- webpack: Tree Shaking,
sideEffects,usedExportsand the pitfalls. - webpack: Code Splitting, entry points, deduplication, dynamic imports, prefetch and preload hints.
- Rollup: configuration options,
treeshake.moduleSideEffects,output.manualChunksandexperimentalMinChunkSize. - React: lazy, lazy components, Suspense and error boundaries.
- web.dev: Reduce JavaScript payloads with code splitting