Skip to content

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?

Code for this question
import { formatScore } from '@examhall/ui';
Pick one

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

The static graph. A bundler starts at each entry, follows every static 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?

Code for this question
export { formatTime } from './time.js';export { emojify } from './emoji.js';export { Composer } from './composer.js';
Pick one

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?

Code for this question
exports.debounce = function debounce(fn, ms) { … };exports.formatBytes = function formatBytes(n) { … };exports.parseMentions = function parseMentions(text) { … };
Pick one

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?

Code for this question
export async function openComposer() {  const { Composer } = await import('./composer.js');  return Composer({ draft: '' });}
Pick one

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?

Code for this question
const { Composer } = await import('./composer.js');document.querySelector('#composer').textContent = Composer().kind;
Pick one

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.

Code for this question
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?

Code for this question
import './locale-en.js';import { locales } from './registry.js';
Pick one

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?

Code for this question
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 Composer
Pick one

Go deeper

Pro tips
  1. 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.
  2. 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/dynamic or import() (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/dynamic and React.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.

Checked against the tools and docs as of 2026-10-09.