awesome-everything RU
↑ Back to the climb

Frontend Architecture

Build pipelines: how source becomes the bytes the browser caches

Crux A build pipeline resolves, transforms, bundles, minifies, and emits — and the two places it bites you are tree-shaking misses that ship an entire library and dev/prod parity bugs from running two different tools.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at junior altitude — the surface
◷ 16 min

A perf alert fires: the main bundle jumped from 180 KB to 410 KB overnight. Nobody added a feature. Git blame finds one line — import _ from "lodash" instead of import debounce from "lodash/debounce". The whole library shipped because lodash is CommonJS and the bundler could not tree-shake it. One import, 200 KB of dead code, and three weeks of users on slow connections paid for it before the alert crossed threshold.

The pipeline is five stages, not one black box

bun run build looks like one command, but it is a conveyor belt with distinct stations, and bugs live at the seams between them.

  1. Resolve — turn every import "x" into a real file on disk. Walk node_modules, honor exports maps, pick ESM vs CommonJS, follow tsconfig path aliases. This is where “module not found” and “wrong version of the same package twice” come from.
  2. Transform — per-file rewriting: TypeScript → JS, JSX → createElement, modern syntax down-leveled for your browser targets. This is the hot loop — it runs once per file, thousands of times.
  3. Bundle — stitch the module graph into a few files, decide code-splitting boundaries, hoist and dedupe shared modules. This is where tree-shaking happens.
  4. Minify — rename locals, drop whitespace and dead branches, collapse the AST. Terser is the careful-but-slow standard; esbuild’s minifier is far faster and nearly as small.
  5. Emit — write the final files to dist/, with content-hashed names and source maps.

The senior mental model: transform is per-file and embarrassingly parallel; bundle is whole-graph and serial. That split explains why one tool can be fast at one stage and slow at another, and why Vite uses two tools (more on that below).

Why Go and Rust tools are 10–100× faster

The old default — Babel for transform, Terser for minify — is written in JavaScript and single-threaded by nature. esbuild (Go) and SWC (Rust) are compiled, multi-threaded, and avoid re-parsing the AST between passes. The result is not a small win. esbuild’s own benchmarks show it bundling a large React app in the low hundreds of milliseconds where Babel/Webpack take tens of seconds — commonly cited as 10–100× depending on the workload. Real migrations report build-stage cuts of 93–96% (a 30s transform step dropping to ~1–2s).

Why so much faster? Three reasons that compound: a compiled language with real threads instead of an event loop, a single shared AST passed through parse/transform/minify instead of re-parsing at each tool boundary, and aggressive memory reuse. The tradeoff a senior weighs: Babel still has the richest plugin ecosystem (custom macros, exotic transforms, legacy targets), so teams that need a specific Babel plugin sometimes keep it for that one file and run esbuild/SWC for everything else.

ToolLangStageSpeed vs JS baseline
BabelJStransform1× (baseline)
SWCRusttransform~20× faster
esbuildGotransform + bundle + minify~10–100× faster
TerserJSminify1× (careful, slow)

Tree-shaking, and the three things that defeat it

Tree-shaking drops exports nobody imports. It only works on static ES modules, because the bundler must prove at build time that a binding is unused. Three things break that proof, and each is a real production incident:

  • CommonJS. module.exports is computed at runtime — the bundler can’t statically know which keys you use. import _ from "lodash" (CJS) drags the whole library: importing just isArray this way costs around 24 KB versus ~46 bytes from lodash-es (ESM). The fix is using the ESM build (lodash-es) or deep imports (lodash/debounce).
  • Side effects. If a module does work on import (registers a polyfill, mutates a global, injects CSS), the bundler must keep it even if its exports look unused — dropping it would change behavior. That is what the "sideEffects": false field in package.json is: a promise to the bundler that importing this file changes nothing observable, so it is safe to drop unused parts. Lie in that field and you ship a broken build; omit it on a clean library and you ship dead code.
  • Down-leveling to CommonJS. Babel’s @babel/preset-env can transform your nice ESM import/export into require/module.exports before the bundler sees it — silently turning a tree-shakeable graph into an opaque one. The bundler then keeps everything. This is the subtle one: the source looks shakeable, the transform stage quietly defeats it.
Why this works

The "sideEffects" flag is a contract, not a hint. false means “every file in this package is pure on import.” If even one file injects CSS as a side effect, you must list it: "sideEffects": ["*.css", "./src/polyfill.js"]. Get it wrong in the safe direction (claim side effects you don’t have) and you only lose some optimization. Get it wrong in the unsafe direction (claim purity you don’t have) and the bundler eliminates code your app depends on — a build that passes CI and breaks at runtime.

Dev and prod run different tools — and that is where parity bugs hide

Modern dev servers (Vite) don’t bundle in development. They serve your source as native ES modules straight to the browser and let the browser request modules on demand, which is why the dev server starts instantly regardless of app size and HMR updates land in single-digit to low tens of milliseconds — it swaps one module, not a rebuilt bundle. Production is different: Vite bundles with Rollup (and increasingly Rolldown), because shipping thousands of unbundled module requests over the network would be catastrophically slow.

Two different code paths — esbuild/native-ESM in dev, Rollup-bundled in prod — means dev and prod can disagree. Different module resolution, different code-splitting, different side-effect handling. The classic symptom is “works on my machine, broken in prod”: a circular import that native ESM tolerates but the bundler reorders, or a dependency that’s tree-shaken in the prod build but fully present in dev. The senior habit: run a real production build locally (vite build && vite preview) before trusting it, and put a smoke test against the built output in CI, not just the dev server.

Pick the best fit

Your team's Webpack + Babel build takes 45s and HMR is 3–4s. You want faster iteration without risking prod. Pick the move.

Quiz

A bundle balloons after someone writes import _ from 'lodash'. Why didn't tree-shaking remove the unused functions?

Quiz

A feature works in the Vite dev server but breaks after vite build. What's the most likely structural cause?

Source maps and content hashes: the emit stage earns its keep

Two outputs of the final stage decide whether prod is debuggable and whether returning users wait. Source maps map minified, bundled dist code back to original source so a stack trace points at Button.tsx:42 instead of index-a1b2c3.js:1:88431. Ship them to your error tracker (privately) even when you don’t expose them publicly, or every production error is unreadable.

Content hashing is the caching contract. The emit stage names files by a hash of their contents — index-a1b2c3.js — so the filename changes only when the bytes change. That lets you serve static assets with Cache-Control: max-age=31536000, immutable (one year), and the browser never re-downloads an unchanged chunk. Change one line and only that chunk’s hash changes; everything else stays cached. The classic mistake is vendor code (React, your big deps) sharing a chunk with app code — then every app deploy busts the vendor cache too. Split vendor into its own chunk so it keeps its hash across your frequent app deploys.

Order the steps

Order the build pipeline stages from source to shipped artifact:

  1. 1 Resolve — turn every import into a real file (node_modules, exports maps, aliases)
  2. 2 Transform — per-file: TS→JS, JSX→createElement, down-level syntax (esbuild/SWC/Babel)
  3. 3 Bundle — stitch the module graph, code-split, tree-shake unused exports
  4. 4 Minify — rename, drop whitespace, prune dead branches (Terser/esbuild)
  5. 5 Emit — write content-hashed files + source maps to dist/
Recall before you leave
  1. 01
    A teammate imports a utility from a CommonJS package and the bundle grows by 200 KB. Explain why tree-shaking failed and the three classes of things that defeat it.
  2. 02
    Why do 'works in dev, breaks in prod' bugs happen with tools like Vite, and how does a senior prevent them?
Recap

A build pipeline is five stages — resolve, transform, bundle, minify, emit — and the seams between them are where bugs live. Transform is per-file and parallel, which is why compiled tools like esbuild (Go) and SWC (Rust) beat JavaScript-based Babel and Terser by 10–100× and cut real build steps by 90%-plus; bundle is whole-graph and serial, which is where tree-shaking happens. Tree-shaking needs static ES modules to prove an export is unused, so it is defeated by CommonJS (runtime exports), by side effects (unless package.json honestly declares them), and by transforms that down-level ESM into CommonJS before the bundler looks — any of which can ship an entire library you never use. Dev and prod often run different tools (native ESM + esbuild in dev, Rollup in prod), so always validate the real production build to catch parity bugs early. Finally, the emit stage earns its keep with source maps for debuggable prod errors and content-hashed filenames that let you cache assets for a year while busting only the chunks that actually changed.

Continue the climb ↑Build pipelines: multiple-choice review
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.