Part 1 · 3 chapters · ~30 min
Build Pipeline
The build as a chain of caches from source to a cached byte, content hashing and the manifest, code splitting and tree shaking with the analyser's three questions and CI budgets, source maps, targets and compression, and the CI gates in three stages under ten minutes with a preview deploy per PR and promotion instead of rebuild.
5
The pipeline as a chain of caches
the stages
- Source → type-check → transform → bundle → optimise → emit → deploy → serve → browser. Each stage is a function of its inputs and should re-run only when they change: tsc incremental per file; per-module transforms in the dev server; task caches (Turborepo, Nx) keyed by input hash and shared across machines.
- Content hashing makes the downstream caches correct:
app-[hash].jschanges its name when its bytes change, so CDN and browser cache it for a year as immutable;index.htmlrevalidates (no-cache with an ETag) so a deploy is seen. The DevTools course's broken-cache diagnosis is this rule inverted. - The manifest maps logical names to hashed files so the HTML can
modulepreloadthe right chunks per route and the server can set headers per type without guessing. - Costs, for a 2,000-module app: install 40 s (5 s cached by lockfile), type-check 60 s (8 s incremental), bundle 25 s, tests 90 s (20 s affected-only), upload 10 s. Four minutes uncached, under one cached: the difference between waiting and context-switching.
- The dev loop is a different pipeline (native ESM per module, transforms on request, HMR, inline maps) that must share transforms, aliases and env handling with production, or "works in dev" becomes a bug class.
FROM SOURCE TO A CACHED BYTE
the pipeline as a chain of caches, each keyed by content
swipe the figure sideways, or tap expand for full screen
1/6
the stages
The stages: source → type-check (tsc, or the bundler's transform with a separate check) → transform (TS and JSX to JS, CSS modules, assets) → bundle (resolve the module graph from the entries, split into chunks) → optimise (tree shake, minify, compress) → emit (files named by content hash, a manifest, source maps) → deploy (upload to storage behind a CDN) → serve (HTTP with Cache-Control) → the browser's cache.
6
Code splitting, tree shaking and the analyser
code
// vite.config.ts: the production pipeline's decisions, written down
export default defineConfig({
build: {
sourcemap: 'hidden', // maps emitted, not referenced: uploaded to the error service, never served to users
target: 'es2022', // no transpiling for browsers you do not support; smaller output
rollupOptions: {
output: {
manualChunks: { // chunk by change frequency: vendor changes quarterly, your code daily
vendor: ['react', 'react-dom', 'react-router'],
ui: ['@app/ui-kit'],
},
entryFileNames: 'assets/[name]-[hash].js', chunkFileNames: 'assets/[name]-[hash].js', assetFileNames: 'assets/[name]-[hash][extname]',
},
},
manifest: true, // .vite/manifest.json: logical name → hashed file, for preloads and headers
},
plugins: [react(), visualizer({ filename: 'stats.html', gzipSize: true })], // the treemap, attached to the PR
})
// package.json "sideEffects": ["*.css"] (CSS imports have effects; everything else is shakeable)
// size-limit: [{ path: "dist/assets/index-*.js", limit: "180 kB" }, { path: "dist/assets/vendor-*.js", limit: "140 kB" }] (fails CI past the line)
// headers at the CDN: /index.html → Cache-Control: no-cache; /assets/* → public, max-age=31536000, immutablewhat ends up where
- The walk: every static import from the entry is in the entry chunk, recursively. Routes imported statically download before the first route renders. The entry is the first-screen budget; everything in it must earn its place.
- Split points: a dynamic
import()(React.lazy) makes a chunk fetched on demand. Route-level first (the biggest win), heavy components second (an editor, a chart library). A split is a promise and a request: it needs a loading state (the React course part 6) and a preload on hover or route match from the manifest. - Shared chunks by change frequency: vendor (react, quarterly), ui-kit, per-route. Too many is a waterfall; too few is the entry again.
manualChunkscontrols it; the analyser tunes it. - Tree shaking drops unused exports from modules the bundler can prove pure: ESM,
"sideEffects": false(or a list of the CSS files that do have effects), no top-level code with effects. A barrel over an impure module, or a CJS library (lodash versus lodash-es), ships whole. One icon through a 2 MB barrel is the classic. - The analyser's three questions: what is in the entry that the first screen does not need (split it); what is in two chunks (hoist or accept); what is included but unused on this route (Coverage confirms; fix the import or the library).
- Budgets in CI: a per-chunk limit that fails the PR past the line, with the delta posted. Without it the entry grows 2 KB per PR and nobody notices until 1.4 MB (part 5's first incident). Set it at the current size and ratchet down.
source maps, targets, compression
- Maps:
hiddenin production: emitted and uploaded to the error service, never referenced from the bundle (the DevTools course part 3). Users do not download your source; your stack traces still resolve. - Target: the oldest browser you support, not ES5 by reflex; transpiling async/await and classes for browsers you do not serve costs 20 to 40% in size and runtime.
- Compression: Brotli at the CDN (or pre-compressed .br files) for text; the Size column in Network shows transferred versus resource (the DevTools course part 4). Images and fonts are already compressed: the FSD course M3 for those.
CODE SPLITTING AND TREE SHAKING
what ends up in which chunk, and why some bytes never leave
swipe the figure sideways, or tap expand for full screen
1/6
the walk
The walk: from main.tsx, every static import is in the entry chunk, recursively. A page that imports every route statically puts every route in the entry: the whole app downloads before the first route renders. The entry chunk is the download budget for the first screen; everything in it must earn its place.
7
CI gates: what blocks, in what order
three stages under ten minutes
- Stage one, under two minutes, parallel: lint with the boundary rules, format, type-check, dependency rules, unit tests on affected packages. Most failures happen here and cost seconds.
- Stage two, under five: the production build (cached by inputs), the size budget with per-chunk deltas posted to the PR, the analyser treemap attached, Lighthouse CI against the preview deploy asserting LCP and TBT budgets (median of three).
- Stage three, under ten: integration tests against a contract-tested API (part 2) and a smoke E2E of five flows that must work, against the preview. The full E2E suite runs on main after merge and nightly, and pages the owner rather than blocking every PR.
what may block, and the preview
- Block: deterministic and under the time budget. Never: a flaky test (quarantine with an owner and a date; a flaky gate trains re-run-and-ignore, which is worse than no gate), a slow suite (off the merge path), a check nobody owns (delete it). The gate list is code and is reviewed quarterly.
- The preview deploy: every PR gets a URL on the same CDN config. Reviewers use it, Lighthouse CI and the smoke E2E run on it, design review happens on it. The single most valuable frontend CI artefact, usually added last.
- Merge and release: squash to main; main deploys to staging automatically; production is a promotion of the same hashed artefact (never a rebuild: the tested bytes are the shipped bytes) behind a flag or a canary. The release is a pointer move; the rollback is the previous pointer. Parts 6 and 7 cover what that means at scale.
the number
Time-to-verdict on a PR, p50 and p95, is the pipeline's metric. Under ten minutes and engineers wait; over twenty and they context-switch, and the queue of half-finished PRs is the cost nobody books.
THE CI GATES
what runs on every PR, in what order, and what each is allowed to block
swipe the figure sideways, or tap expand for full screen
1/6
stage one
Stage one, under two minutes, in parallel: lint (ESLint with the boundary rules), format check, type-check (tsc incremental, or per package), dependency rules (dependency-cruiser), unit tests on affected packages only (the task runner knows what changed). Fails here are cheap and obvious; most PRs that fail, fail here.