Appearance
6.6 — Build Tooling
You add one line to a component:
ts
import { format } from 'date-fns';The bundle grows by 4 KB. A colleague adds a similar-looking line:
ts
import _ from 'lodash';and the bundle grows by 71 KB, for one call to _.debounce. Both imports look the same. The difference is decided by things neither line mentions: which module format the package ships, whether the bundler can prove that unused code is safe to delete, and how the package declares itself in its package.json.
This page is about the machinery that makes that decision, because "the bundle got big and nobody knows why" is the most common build problem there is.
1. Why a bundler still exists
Browsers have supported ES modules natively since 2017 (Chapter 6.3.2). So why not just serve the source?
Try it on a real application and you find out within a second. A medium project has several thousand modules, and native ESM fetches each one separately, discovering imports only after the importing file has arrived and been parsed. That is a request waterfall thousands deep. On a fast local connection it is slow; over the internet it is unusable.
That is the main reason, and there are five more:
Bare specifiers do not work in a browser. import { format } from 'date-fns' means nothing to a browser — it is not a URL. Something must resolve it to a path (import maps can do this, with their own trade-offs).
Not everything is JavaScript. TypeScript, JSX, CSS modules, SVG-as-component, images with content hashes.
Half of npm is still CommonJS, which browsers cannot load at all.
Minification and compression need a build step regardless.
Dead code removal and code splitting need a view of the whole graph.
So the bundler's job, stated once: take an entry point, follow every import to build a graph of modules, transform each one, remove what is unreachable, and emit a small number of files a browser can load efficiently.
2. Module resolution, and the fields that decide what you get
When the bundler sees import { format } from 'date-fns', it walks up looking for node_modules/date-fns and then reads that package's package.json to decide which file to load. Several fields can answer, and they were added at different times:
json
{
"main": "./dist/index.cjs", // (1) the original field — CommonJS
"module": "./dist/index.mjs", // (2) a convention, never standardised
"browser": "./dist/browser.js", // (3) swap out Node-specific code
"exports": { // (4) the modern, authoritative one
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
},
"./locale/*": "./dist/locale/*.mjs"
},
"sideEffects": false // (5) the tree-shaking promise
}Line (4) exports is the one that matters now. It does two things: it maps conditions (import versus require, browser versus node, development versus production) to different files, and it seals the package — anything not listed cannot be imported. That second part is why upgrading a dependency sometimes breaks a deep import like lodash/fp/curry that worked for years: the package added exports and stopped exposing its internals.
The dual-package hazard is the failure this creates. If a package ships both a CommonJS and an ESM build, and part of your dependency tree requires it while another part imports it, you get two separate copies with separate module state. For a stateless utility that is only wasted bytes. For anything holding state — a client instance, a registry, a singleton (Chapter 9.4.6) — it is a real bug where two halves of your application disagree about the same object.
Line (5) sideEffects: false is a promise to the bundler, explained next.
3. Tree shaking, and why it usually does not work
Tree shaking is removing exports nothing imports. It sounds like a straightforward reachability analysis and it is limited by one thing: the bundler must be able to prove that deleting the code changes nothing observable.
It requires ES modules. ESM imports are static — the names are known without running anything, so the graph can be built by reading. CommonJS is dynamic: require(someVariable) and module.exports can be built at runtime, so nothing can be proved. This is the whole answer to the lodash question at the top. lodash is CommonJS, so importing anything from it pulls in everything. lodash-es is the ESM build and shakes properly, and import debounce from 'lodash/debounce' reaches the single file directly.
It requires knowing about side effects. Consider:
ts
import './analytics/register'; // (1) imported for its effect only
import { formatPrice } from './money'; // (2) only this one is usedLine (1) exports nothing. A naive shaker would delete it and silently break analytics. So bundlers are conservative by default and keep any module that might do something at import time.
"sideEffects": false in a package's package.json is the author saying "importing any of my modules does nothing on its own, so delete freely". An array lists the exceptions:
json
"sideEffects": ["**/*.css", "./src/polyfills.js"]CSS is the one everybody gets wrong. import './button.css' exists purely for its side effect. Setting sideEffects: false without excluding CSS deletes your styles, and the symptom is an unstyled component in production only.
The four things that quietly defeat it
Barrel files. An index.ts that re-exports fifty modules means importing one thing touches all fifty. Bundlers have improved at seeing through them, but a barrel that re-exports something with side effects — or that hits the dual-package hazard — pulls the lot. Barrels are convenient for authors and expensive for consumers; import from the specific path in application code.
Side effects at module top level. A class created at import time, a window property assigned, a registry populated. Anything the bundler cannot prove is inert keeps the module alive.
Class methods. A large class is one binding. If you import the class, every method comes with it, however few you call. Standalone functions shake at the function level; classes do not.
Namespace imports. import * as utils followed by dynamic property access defeats the static analysis entirely.
The /*#__PURE__*/ annotation is the escape hatch for library authors: it marks a function call as having no side effects, so the result can be dropped if unused. You will see it in library output and rarely need to write it.
4. Code splitting
One file for everything means a user who opens the home page downloads the checkout, the admin panel and the rich text editor. Splitting produces several chunks loaded on demand.
A dynamic import() is a split point (Chapter 6.3.2). The bundler sees it, emits a separate chunk, and inserts the loading code.
Three splits worth having by default:
By route. Each page is a chunk. This is automatic in every meta-framework and is the highest-value split.
By heavy dependency. A charting library, a rich text editor, a PDF viewer, a map — anything large that only some users touch.
Vendor and shared chunks. Dependencies change less often than your code, so putting them in a separate chunk means a deploy invalidates your code's cache and not the 200 KB of framework.
The mistake in the other direction is over-splitting. Fifty tiny chunks means fifty requests, and a chunk under a few kilobytes costs more in request overhead and compression loss than it saves. HTTP/2 multiplexing (Chapter 5.6.1) reduced but did not remove this.
5. The bundlers, and what actually differs
webpack
The one that defined the category. Everything is a module — JavaScript, CSS, images, fonts — handled by a loader that transforms a file type, and plugins that hook into the build lifecycle. Enormously capable, and the configuration surface is the price.
It remains the right answer where you need something unusual, and the wrong default for a new project purely on developer experience: a large application's cold start is measured in tens of seconds because it bundles everything before serving anything.
Vite
Vite's insight is that development and production have different requirements, so they should use different strategies.
In development, Vite does not bundle. It serves your source files over native ESM and transforms each one on demand as the browser requests it. Start-up is near-instant regardless of project size, because nothing is built up front. Editing a file re-transforms exactly that file.
Two supporting tricks make this work:
Dependency pre-bundling. node_modules packages are pre-bundled once with esbuild. This does two jobs: it converts CommonJS packages to ESM so the browser can load them, and it collapses a package that internally imports 600 files into one — otherwise the "no bundling" model would produce 600 requests for one import.
Rewriting bare specifiers to real URLs as it serves each file, so import 'date-fns' becomes /node_modules/.vite/deps/date-fns.js.
In production, Vite bundles with Rollup, because the unbundled model's request waterfall is unacceptable over a real network. People sometimes ask why Vite does not ship its dev approach to production; that is the answer.
esbuild and SWC
Written in Go and Rust respectively, parallel across cores, 10–100× faster than JavaScript-based tools. They are used inside other tools — Vite uses esbuild for pre-bundling and transforms; Next.js uses SWC for compilation.
What they do not do is type-check. They strip TypeScript types without verifying them, which is what makes them fast. Type checking is a separate tsc --noEmit run, and this catches teams out: the build succeeds with type errors in it unless something else is checking. Run the type check in the same command as the tests, not as an optional step.
The current picture
Vite for almost any new application. webpack where an existing configuration or an unusual requirement justifies it. Rspack and Turbopack are Rust rewrites of the webpack model, aimed at the same configuration surface with the speed of the native tools. Rolldown is a Rust Rollup, intended to unify Vite's development and production paths.
The direction is unmistakable: the JavaScript-in-JavaScript build tool is being replaced by native code underneath the same interfaces.
6. Hot module replacement, and how it actually works
Save a file and the component updates without a page reload, keeping its state. This looks like magic and is four mechanisms.
The dev server keeps the module graph — which module imports which — from the same analysis used for bundling.
A WebSocket connection (Chapter 5.8) runs between the server and the page, opened by a small client script the dev server injects.
A module can declare that it can be replaced:
js
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
// Swap in the new version without reloading the page.
render(newModule.App);
});
}Invalidation propagates upward. When you save Button.tsx, the server recompiles it and asks: does this module accept its own update? If yes, send it and stop. If no, ask its importers — Toolbar.tsx, then Page.tsx — walking up until it finds a module that accepts. If it reaches the entry point without finding one, it gives up and reloads the whole page. That is exactly what you observe: editing a leaf component is instant, and editing a shared store or a root file reloads everything.
React Fast Refresh is the framework-specific layer that makes state survive. It rewrites each component to register itself with a runtime, and on an update it swaps the function while keeping the fiber and its hook state (Chapter 6.4.2). Its rules explain behaviour you have probably seen:
- A file that exports only components keeps state.
- A file that exports a component and something else — a constant, a hook, a helper — cannot be safely swapped, so it falls back to a full reload.
- Changing the number or order of hooks forces a remount, because the existing hook list can no longer be matched.
- An anonymous default-exported arrow function has no stable identity, so it may remount.
"Why does my state reset on every save" is nearly always the second or third of those.
7. Source maps
Your production file is one line of mangled names. A stack trace pointing at main.a3f9.js:1:48210 is useless. A source map is a JSON file mapping generated positions back to original ones.
json
{
"version": 3,
"sources": ["src/checkout/Basket.tsx"],
"names": ["applyCoupon", "total"],
"mappings": "AAAA,SAASA,YAAT,CAAsBC,KAAtB;…"
}The mappings string is a compact encoding — base64 VLQ — of a list of segments, each saying "column X in the generated file corresponds to line Y, column Z of source N". It is relative and delta-encoded, which is why it looks like noise and why the file is far smaller than the mapping it represents.
The choices that matter:
source-map produces a full, accurate separate file — slow to build, correct. eval-source-map is fast to rebuild and development-only. hidden-source-map generates the map without the //# sourceMappingURL= comment at the end of the bundle.
That last one is the production answer. Generate the map, upload it to your error tracking service, and do not deploy it to your web server. A public source map hands anyone your original source, including comments and internal naming. With the comment omitted, your error tracker still symbolicates stack traces because you uploaded the map to it directly, and nobody else can fetch it.
8. Module Federation and micro frontends
Module Federation lets one deployed application load a module from another deployed application at runtime.
js
// The "remote" — a separately built and deployed application.
new ModuleFederationPlugin({
name: 'checkout',
filename: 'remoteEntry.js', // (1) the manifest
exposes: { './Basket': './src/Basket' }, // (2)
shared: { react: { singleton: true, requiredVersion: '^18.2.0' } }, // (3)
});js
// The "host" loads it at runtime — not at build time.
const Basket = React.lazy(() => import('checkout/Basket'));Line (1) is a small file the host fetches, describing what is available and where the chunks are. Line (2) lists the exposed modules. Line (3) is the important one: singleton: true means both applications must use the same React instance, because two copies of React in one page means two copies of the hook dispatcher, and hooks fail immediately with confusing errors.
The promise: teams deploy independently. The checkout team ships without rebuilding the host.
The costs, which are frequently understated:
- Version skew is now a runtime problem. Two applications on incompatible versions of a shared dependency fail in the browser, in front of a user, not in CI.
- Duplicate dependencies unless every shared package is configured, and every shared package is a coordination point between teams.
- Debugging spans deployments. A stack trace crosses a boundary between two codebases with two source maps and two release cycles.
- The host cannot fail gracefully by default. If a remote's
remoteEntry.js404s, that region of the page is broken; you must build the fallback yourself. - Shared design tokens, routing and authentication all become cross-team contracts.
When it is genuinely worth it: several independent teams, each owning a distinct region of a large product, with deployment cadences that genuinely conflict, and enough platform investment to run the shared-dependency governance. In other words, an organisational problem being solved with a technical tool — which is fine, as long as everyone knows that is what it is.
When it is not: one team. A micro-frontend architecture for a single team is all of the cost and none of the benefit, and it is one of the most reliably regretted architecture decisions in frontend.
Vite's ecosystem now has module-federation support too, but the plugin situation is less mature than webpack's, which is worth checking before committing.
9. Differential loading, and why it faded
The idea: build twice, ship modern syntax to modern browsers and transpiled code to old ones, using a browser feature as the switch:
html
<script type="module" src="/app.modern.js"></script>
<script nomodule src="/app.legacy.js"></script>A browser that understands type="module" runs the first and ignores the second by definition; an old browser does not understand modules, ignores the first, and runs the nomodule one.
It worked, and it has largely faded because the reason for it went away: Internet Explorer is gone, and the browsers still in use all support modern syntax. Most projects now set a browserslist covering recent browsers and ship one build.
browserslist is still the control worth knowing, because it feeds the transpiler, the CSS prefixer and the minifier at once:
json
"browserslist": ["> 0.5%", "last 2 versions", "not dead"]Widen it and every tool downstream produces older, larger output. Tightening it is one of the cheapest bundle-size wins available and it takes one line.
10. ESLint, and what a linter actually is
A linter is not a compiler and not a type checker. It is a program that parses your code into an abstract syntax tree (Chapter 3.1) and runs rules that walk that tree looking for patterns.
The pipeline, once:
Parse. A parser produces the AST — espree by default, @typescript-eslint/parser for TypeScript.
Traverse. ESLint walks the tree, calling each rule when it reaches a node type the rule registered for.
Report. A rule that matches reports a message with a location and severity.
Fix. A rule may also supply a fixer describing a text replacement, which is what --fix applies.
A rule is genuinely small:
js
export default {
meta: { type: 'problem', fixable: 'code' },
create(context) {
return {
// Called for every === / !== / == node in the file.
BinaryExpression(node) {
if (node.operator === '==' && node.right.value !== null) {
context.report({
node,
message: 'Use === instead of ==.',
fix: (fixer) => fixer.replaceText(node, '…'),
});
}
},
};
},
};The object returned from create maps AST node types to handlers. That is the entire extension model, and it is why writing a project-specific rule is a reasonable afternoon's work rather than a research project.
Flat config (eslint.config.js) replaced the older cascading .eslintrc. The difference is that it is a plain array of configuration objects evaluated in order, with explicit imports for plugins, rather than a hierarchy of files merged by directory. It is more verbose and far easier to reason about — "why is this rule on" now has an answer you can read.
Type-aware rules are the expensive part. @typescript-eslint can use the type checker to find things no syntax rule can — a floating promise, an unnecessary await, a comparison that is always false. That requires running the TypeScript program, which can take a project's lint from seconds to minutes. Worth it for the rules that catch real bugs; scope them to source directories and leave them out of editor-on-save if the delay is painful.
Linting and formatting are different jobs. A formatter rewrites layout deterministically and has no opinions about correctness. Running both means turning off every stylistic rule in the linter, or the two fight over the same lines. Leave the linter to correctness and the formatter to appearance.
11. The commands worth knowing
Chapter 3.10 covered package managers properly. The build-relevant ones:
npm ci deletes node_modules and installs exactly what the lockfile says, failing if package.json and the lockfile disagree. This is what continuous integration and Docker builds should use — npm install may update the lockfile, which means your build is not reproducible.
npm run <script> runs a package.json script with node_modules/.bin on the PATH, which is why vite works in a script and not in your shell.
npx <tool> runs a package binary, downloading it temporarily if it is not installed.
npm ls <package> shows why a package is present and at which versions — the first command to run when you suspect a duplicate dependency.
npm run build -- --analyze or the bundler's analyser plugin gives you the treemap of what is in the bundle. Run it before optimising anything; the thing making your bundle large is very often a package you did not know was there.
For Angular, ng generate scaffolds to the project's conventions, ng serve runs the dev server, ng build --configuration production builds, and ng update is the one that distinguishes Angular — it runs migration scripts that rewrite your code for a new version, which is the payoff of a framework that owns its own conventions.
What the interviewer will push on
"Why do we still bundle when browsers support modules?" The request waterfall — thousands of modules discovered one level at a time. Then the supporting reasons: bare specifiers, CommonJS dependencies, non-JavaScript assets, minification, and needing the whole graph for tree shaking and splitting.
"Why did importing one lodash function add 71 KB?" lodash is CommonJS, whose dynamic structure cannot be statically analysed, so nothing can be shaken. lodash-es, or a direct path import. This is the question that tests whether you know tree shaking is about provability, not cleverness.
"What breaks tree shaking?" CommonJS, top-level side effects, barrel files, class methods (one binding), namespace imports. And sideEffects: false without excluding CSS, which deletes your styles in production only.
"How does Vite start instantly?" It does not bundle in development — it serves source over native ESM and transforms on demand, with dependencies pre-bundled once by esbuild to convert CommonJS and collapse hundreds of internal files. Production still bundles with Rollup, because the unbundled model waterfalls over a real network.
"How does hot module replacement work?" A module graph, a WebSocket, modules that can accept an update, and invalidation walking upward until something accepts — falling back to a full reload at the entry. That last mechanism explains exactly which edits reload the page.
"Should you ship source maps to production?" Generate them, upload them to the error tracker, and use hidden-source-map so the URL comment is not in the bundle. You get symbolicated stack traces; the public does not get your source.
"When are micro frontends worth it?" Several independent teams with genuinely conflicting deployment cadences and platform investment to govern shared dependencies. Then list the costs honestly — runtime version skew, duplicated dependencies, cross-deployment debugging, and remotes that fail in the browser rather than in CI. A candidate who only lists benefits has not run one.
"How does ESLint work?" Parse to an AST, traverse, rules subscribe to node types, report with an optional fixer. Then note that type-aware rules run the TypeScript program and are the reason a lint takes minutes.
One thing to volunteer: bring up the dual-package hazard — a library shipped as both CommonJS and ESM, loaded both ways in one dependency tree, giving two copies with separate state. For a stateless helper that is wasted bytes; for anything holding state it is a bug where two halves of your application disagree. It is the kind of thing you only know from having chased it.
Recall
- Bundling survives native ESM mainly because of the request waterfall — thousands of modules discovered one level at a time — plus bare specifiers, CommonJS dependencies, non-JS assets, minification, and needing the whole graph.
exportsinpackage.jsonmaps conditions to files and seals the package, which is why deep imports break on upgrade. The dual-package hazard: both formats loaded in one tree gives two copies with separate state.- Tree shaking needs provability: ESM's static structure, and knowledge of side effects. CommonJS cannot be shaken, which is the lodash answer.
sideEffects: falsemust exclude CSS or your styles vanish in production. - Also defeated by: top-level side effects, barrel files, class methods (one binding), namespace imports.
- Split by route, by heavy dependency, and vendor separately so a deploy does not invalidate the framework chunk. Over-splitting costs more in requests than it saves.
- Vite does not bundle in development — native ESM, transform on demand, with esbuild pre-bundling dependencies to convert CommonJS and collapse hundreds of internal files. Production bundles with Rollup because unbundled waterfalls over a real network.
- esbuild and SWC are Go/Rust and 10–100× faster, and they do not type-check —
tsc --noEmitmust run separately or type errors ship. - HMR = module graph + WebSocket + modules that
accept+ invalidation walking upward until something accepts, falling back to a full reload. React Fast Refresh loses state when a file exports a non-component, or when hook count/order changes. - Source maps encode positions as base64 VLQ. In production use
hidden-source-map: upload to the error tracker, do not serve publicly. - Module Federation loads a module from another deployment at runtime;
singleton: trueon React is mandatory. Costs: runtime version skew, duplicate dependencies, cross-deployment debugging, remotes failing in front of users. Worth it for several teams with conflicting cadences; regretted by single teams. - Differential loading faded with Internet Explorer;
browserslistis still the one line that decides how old your output is. - ESLint = parse to AST → traverse → rules subscribe to node types → report with an optional fixer. Flat config replaced cascading
.eslintrc. Type-aware rules run the TypeScript program and are why linting gets slow. Linting is correctness; formatting is appearance; do not let them fight. npm ciin CI,npm lsto find duplicates, and run the bundle analyser before optimising.
Self-test: Why can't a CommonJS package be tree-shaken? · What exactly does sideEffects: false promise, and what does it break? · Why does Vite bundle for production but not for development? · Which edit forces a full reload instead of a hot update, and why? · Why is hidden-source-map the production choice? · What fails at runtime rather than in CI with Module Federation?
Next: 6.7 turns the whole Part toward measurement — the three metrics that are actually scored, what each one is really measuring, and the specific fixes for each, in the order of how much they typically buy.