Bundling, tree shaking and code splitting
A bundle holds everything reachable that might run on load; in esbuild only ES modules shake, and import() moves code to a later download.
Predict first
Before you read: predict
ExamHall's candidate app shows a results badge that needs one helper from the shared UI package:
The package's index.js re-exports three modules:
export { formatScore } from './score.js';
export { ScoreChart } from './charts.js';
export { RichEditor } from './editor.js';
charts.js starts with import { ChartEngine, allPlugins } from './chart-engine.js'; and then ChartEngine.register(...allPlugins);. editor.js declares RichEditor and runs nothing else. The package's package.json has no sideEffects field. The app is built for production with esbuild (bundled and minified).
Which of the package's code ends up in the candidate bundle?
import { formatScore } from '@examhall/ui';The 30-second version
A bundler follows static imports from each entry and copies whatever it reaches. Tree shaking then drops unused exports (in esbuild, only from ES modules), and it keeps any code that runs on load. sideEffects: false lets whole unused files go. import() moves code into a chunk fetched later. Read the analyser before cutting.
A bundle holds everything reachable that might run on load; in esbuild only ES modules shake, and import() moves code to a later download.
How it works
Step 1 of 11
import, and puts all it reaches into that entry's output file.In the kitchen
- the officer walks each team's experiment plan and loads every item it names, and every item those items need → the static graph from each entry (step 1)
Analogy: An Antarctic station's yearly resupply ship (polar logistics)
| On the resupply ship | In the bundle |
|---|---|
| the officer walks each team's experiment plan and loads every item it names, and every item those items need | the static graph from each entry (step 1) |
| a catalogue item no plan names stays ashore | an unused export dropped (step 2) |
| an old supplier sells only sealed pallets, so one wanted item brings the pallet | CommonJS kept whole (step 3) |
| a heater crate must be switched on when unloaded, so it sails whether or not anyone asked for it | code that runs on load (step 4) |
| ordering one item from a supplier's "starter kit" list sails every heater crate on that list | a barrel import (step 5) |
| a sticker "nothing here needs switching on" lets whole crates stay; if it lies, the hut is cold all winter | sideEffects: false and a false promise (steps 6, 7) |
| what a team needs later comes on the summer plane when they ask; a team that needs it on day one waits for the plane | import() chunks and the extra trip (steps 8, 10) |
| the manifest lists each crate, its weight and which plan asked for it | the analyser (step 11) |
In real terms: the output is everything reachable that cannot be proven inert, and a split moves code to a later request rather than removing it.
Breaks down when: the officer can open a crate to see whether it needs power, while a bundler only reads code and must assume a call might do something. And a ship sails once a year, while a browser can fetch a chunk at any moment.
Contrasting case: Server Components (next-server-client-components) ship no code at all; a split chunk still ships, only later.
Examples, run for real
The fixtures are scaled down, so the bytes are small; the ratio is what carries over.
4.1 ExamHall EXH-3390: the essay editor in every candidate's download
ExamHall's candidate bundle carried the admin charting engine and the essay question's rich-text editor to every candidate (EXH-3390). The entry imported formatScore through @examhall/ui's barrel, whose charts.js registers the engine's plugins on load, and imported RichEditor statically, though only essay questions open it. MR !3690 imports formatScore from @examhall/ui/score.js directly and loads the editor with await import('../essay-editor.js') inside openEssay:
4.1 EXH-3390, barrel import + static editor: candidate.js 681 B · formatScore yes · chart engine yes · essay editor yes · other files 0
4.1 MR !3690, direct import + import(): candidate.js 234 B · formatScore yes · chart engine no · essay editor no · other files 1 (essay editor in one chunk)
4.1 candidate.js after / before: 0.34 (two decimals)
The direct import never touches charts.js, so its registration never enters the graph (steps 1, 4, 5). The editor still ships, in its own chunk, fetched the first time a candidate opens an essay (step 8).
4.2 Chat: reading the analyser before cutting
A chat room page, room.js, imports formatTime from the chat-ui package and its own MessageList. The bundle looked too large for two small imports. esbuild's analyzeMetafile (verbose) answers what is in it and why:
4.2 o/room.js ───────────────────── 10.2kb ─ 100.0%
4.2 ├ pkgs/chat-ui/emoji-data.js ── 9.8kb ── 96.5%
4.2 │ └ pkgs/chat-ui/emoji.js
4.2 │ └ pkgs/chat-ui/index.js
4.2 │ └ src/room.js
The largest input is an emoji table nobody on this page uses. The chain under it names the culprit: room.js reached it through chat-ui's index.js, whose emoji.js builds its index on load (steps 5, 11). The fix follows the chain, not a guess.
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A chat app shows each message's time with import { formatTime } from 'chat-ui';. The package's index.js:
emoji.js has, at its top level, const EMOJI_INDEX = buildEmojiIndex(EMOJI_DATA);, and composer.js only declares Composer. The package's package.json says "sideEffects": false.
Built with esbuild for production, which of chat-ui's code is in the bundle?
export { formatTime } from './time.js';export { emojify } from './emoji.js';export { Composer } from './composer.js';Question 2 of 7
The message composer saves drafts with import { debounce } from 'chat-utils';. chat-utils ships only this index.js:
A reviewer asks why the bundle grew more than debounce should explain.
Built with esbuild for production, which of chat-utils' functions does the bundle contain?
exports.debounce = function debounce(fn, ms) { … };exports.formatBytes = function formatBytes(n) { … };exports.parseMentions = function parseMentions(text) { … };Question 3 of 7
A chat app has two pages, room.js and dm.js. Each starts with import { avatarUrl } from './shared/avatar.js'; and opens the composer like this:
esbuild builds both pages as entries in one build, with code splitting on.
How many JavaScript files does it write?
export async function openComposer() { const { Composer } = await import('./composer.js'); return Composer({ draft: '' });}Question 4 of 7
The chat room page draws its message composer as soon as it loads. MR !3692 "lazy-loads the composer" to make room.js smaller; at the top level of room.js it now runs:
esbuild builds room.js with splitting on, and the page loads it with <script type="module" src="/room.js">. The page is opened in Chromium and nobody clicks.
Which account of the composer's code holds?
const { Composer } = await import('./composer.js');document.querySelector('#composer').textContent = Composer().kind;Question 5 of 7
The direct-message page's dm.js:
chat-ui (in fixtures/sandbox/pkgs/, which you may not edit) is a barrel over time.js, emoji.js and composer.js, with no sideEffects field. The goal: the file esbuild writes for dm.js holds neither the emoji index nor the composer, stamp still prints Sent at 09:30 for a 09:30 UTC message, and openComposer() still returns the composer.
Rewrite only dm.js so that all three hold.
import { formatTime, Composer } from 'chat-ui'; export const stamp = (message) => formatTime(message.sentAt);export async function openComposer() { return Composer({ draft: '' });}Question 6 of 7
In chat-ui, time.js starts:
and locale-en.js is one line after its import: locales.set('en', { sent: 'Sent at' });. formatTime reads the locale from locales. MR !3691 adds "sideEffects": false to chat-ui's package.json, "so the app drops the chat-ui files it never uses". The app shows a message sent at 09:30 UTC with formatTime.
Which review comment is right?
import './locale-en.js';import { locales } from './registry.js';Question 7 of 7
A chat room's entry has three imports:
highlight escapes the code and wraps it in a <pre>; it never reads GRAMMARS, a table the package exports for editors. The room uses debounce, highlight and Composer, nothing else. You build it with esbuild and open the analyser's report.
Which input is at the top, with the most bytes in the output?
import { debounce } from 'chat-utils'; // CommonJS, 12 KB source, mostly an emoji-name tableimport { highlight } from 'chat-syntax'; // ES modules, 24 KB source, mostly a GRAMMARS tableimport { Composer } from './composer.js'; // 6 KB source, every line used by ComposerGo deeper
Pro tips
- Read the analyser's chain before you cut. Build with a metafile and print the report verbose; the chain under the largest input names the import that pulled it in, so the fix lands on that line. When: a bundle has grown and nobody knows why (step 11). Verify.
- Defer what the first screen does not draw. Next's lazy-loading guide: "For example, you might want to defer loading a modal until a user clicks to open it." When: choosing what to put behind
next/dynamicorimport()(steps 8, 10). Status: DOC (Next.js docs, Lazy loading); not run.
Source
Valid as of 2026-10-09.
- webpack, Tree Shaking guide (named in the curriculum row): https://webpack.js.org/guides/tree-shaking/ ·
webpack-tree-shaking.txt. "It relies on the static structure of ES2015 module syntax, i.e. import and export"; "The CSS file import gets dropped!" (under"sideEffects": false); "sideEffects is much more effective since it allows to skip whole modules/files and the complete subtree"; "If a consumer only imports specific components, the polyfill import might be skipped entirely if not properly marked with side effects". Supports steps 2, 5, 6, 7. - Next.js docs, Lazy loading (named in the curriculum row): https://nextjs.org/docs/app/guides/lazy-loading ·
next-lazy-loading.txt. "There are two ways you can implement lazy loading in Next.js";next/dynamicandReact.lazy(). Supports step 8. - MDN,
import()(named in the curriculum row): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import ·mdn-import.txt. "dynamic imports are only evaluated when needed". Supports steps 8, 10. - esbuild API docs (the pinned bundler's own reference): https://esbuild.github.io/api/ ·
esbuild-api.txt. "Note that esbuild's tree shaking implementation relies on the use of ECMAScript module import and export statements. It does not work with CommonJS modules."; "Code shared between multiple entry points is split off into a separate shared file that both entry points import."; "Code referenced through an asynchronous import() expression will be split off into a separate file and only loaded when that expression is evaluated."; "esbuild only considers code removable as dead code if it can be sure that there are no hidden side effects". Supports steps 1–4, 8, 9, 11. - MDN,
rel="modulepreload": https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel/modulepreload ·mdn-modulepreload.txt. "provides a declarative way to preemptively fetch a module script". Background for P4's belief world B and step 10.
Related lessons
Checked against the tools and docs as of 2026-10-09.