The discipline of shipping less JavaScript: split the bundle so users download only what a page needs, tree-shake away the dead code, pick a bundler for the right reasons — and govern it across many teams.
Every other byte (HTML, CSS, images) is cheaper than JS. A JS byte gets downloaded, parsed, compiled, and executed on the main thread — and that execution is what blocks INP and delays interactivity (Lessons 02–03). So the whole game is: send the least JS that makes this page work, and no more. Three tools, in order of impact:
“JS is the most expensive byte — it's parsed and run on the main thread. So I optimize for what we ship, not just what we cache: split by route, tree-shake the rest, and put a size budget in CI.”
A bundler creates a new chunk at every dynamic import() — a “split point.” That chunk
is fetched only when the code path runs. The static import at the top of a file always ends up in the
initial bundle; import() is the lever. [MDN]
// static — always in the initial bundle
import { formatPrice } from './utils';
// dynamic — its own chunk, fetched only when this runs
button.addEventListener('click', async () => {
const { openMap } = await import('./heavy-map-widget');
openMap();
});
Each route loads its own chunk. A user on the search page never downloads the checkout flow's code. In React:
React.lazy(() => import('./Checkout')) behind a <Suspense> fallback;
frameworks (Next/Remix) do it per-route automatically.
Lazy-load the expensive, not-immediately-visible thing: a modal, a rich-text editor, a charting lib, a map.
Pair with IntersectionObserver or interaction to trigger the import just before it's needed.
Pull stable node_modules deps into their own chunk(s). Your app code changes every deploy;
React/lodash don't. A separate, content-hashed vendor chunk keeps its filename across deploys →
returning users re-download only your app code, vendor stays in cache.
React's official pattern is lazy + Suspense; the import must resolve to a module with a
default export. [react.dev]
Vendor splitting in practice: route/component splits cut how much JS loads; vendor splitting
changes how well it caches. You configure it (Webpack optimization.splitChunks, Vite/Rollup
manualChunks) rather than via import(). The nuance: granularity is a trade-off.
One giant vendor chunk means a single dep bump busts the cache for the whole vendor bundle for every user;
too many tiny vendor chunks bring back the request-waterfall problem. Split into a few sensible groups (e.g.
framework vs the long tail), and let content hashing do the rest. [web.dev — granular chunking]
const Checkout = React.lazy(() => import('./Checkout'));
<Suspense fallback={<Skeleton/>}>
<Checkout/>
</Suspense>
<link rel=prefetch>
/ router prefetch) so the network cost is paid before the click.
“Split on routes first, then on heavy below-the-fold components — but not per-component. Past a point you're just trading bundle size for a request waterfall. I prefetch the likely-next chunk so the split is invisible to the user.”
A classic consequence: you route-split, and now clicking into a route shows a loading spinner — users say the app got slower. You didn't add cost, you moved it: that route's code is no longer in the initial bundle, so it's discovered at click time → fetch → render. The fix is to pay that cost before the click, not to undo the split:
touchstart / focus, or when
the link enters the viewport (IntersectionObserver). Routers do this: Next's
<Link> prefetches in-viewport links, React Router has prefetch. By click time
the chunk is cached → instant nav. (Lesson 01 — <link rel=prefetch> / Speculation Rules.)
startTransition/useTransition so the current page stays interactive while the next
chunk loads, instead of yanking to a Suspense fallback. [react.dev]Save-Data / slow connections. The honest framing: code-splitting trades initial-load time for
navigation time — prefetch-on-intent is how you get both.
“Splitting moved the cost from load to navigation; I pay it back with prefetch-on-intent — hover or
in-viewport — and keep the old page interactive with startTransition. So the click feels instant
without re-bloating the initial bundle.”
Tree-shaking = dead-code elimination across modules. The bundler builds a graph of every
export and which are actually imported, then drops the unreachable ones. The whole thing rests on one
property: [MDN]
import/export are static — bindings are known at
build time without running the code, so the bundler can prove an export is unused. CommonJS
require() is dynamic (it's a function call returning a value at runtime), so it can't be
statically shaken — a major reason ESM matters. Prefer the -es / ESM build of a dependency (e.g.
lodash-es).sideEffects: false mattersA side effect is code that does something just by being imported — a polyfill,
import './styles.css', registering a global, mutating a prototype. By default a bundler must be
conservative: even if you use nothing from a module, it may have to keep it in case importing it
does something. "sideEffects": false in package.json is a promise to the bundler:
“my files do nothing on import — if an export isn't used, drop the whole file.” That upgrades tree-shaking
from pruning unused exports to pruning entire unused modules. [webpack]
// package.json — "I have no import-time side effects"
{ "sideEffects": false }
// or list the files that DO (so CSS isn't dropped):
{ "sideEffects": ["*.css", "./src/polyfills.js"] }
sideEffects:false that drops your CSS or a needed polyfill; (3) barrel files
(index.js re-exporting everything) that pull in a whole library when you wanted one function; (4)
importing the whole namespace — import _ from 'lodash' defeats it,
import debounce from 'lodash/debounce' or lodash-es doesn't. Verify with the bundle
analyzer, don't assume.
“Tree-shaking only works because ESM imports are static — CommonJS can't be shaken.
sideEffects:false lets the bundler drop whole unused files, not just unused exports. The usual
killers are barrel files and importing a lib's whole namespace.”
Have an opinion here. The split that matters: old guard written in JavaScript (Webpack, Rollup) vs new guard written in Go/Rust (esbuild, SWC, Turbopack, Oxc/Rolldown) that are 10–100× faster at the parse/transform step. And the architectural trick Vite popularized: don't bundle at all in dev.
| Tool | What / written in | Sweet spot & trade-off |
|---|---|---|
| Webpack | JS — the mature incumbent | Vast plugin/loader ecosystem, handles anything. Cost: complex config, slower builds & dev start. Still everywhere in legacy apps. |
| Rollup | JS — library bundler | Cleanest ESM output + best-in-class tree-shaking → long the standard for publishing libraries; was Vite's prod bundler (until Rolldown). |
| esbuild | Go — a transpiler/bundler | Extreme speed; was Vite's dep pre-bundler. Thin plugin API & code-splitting → rarely the sole prod bundler for a big app. |
| Oxc + Rolldown | Rust — the unifying toolchain | Oxc = a Rust JS/TS toolchain (parser, oxlint, oxfmt, transformer, minifier, resolver); Rolldown = the Rust bundler built on it, Rollup-compatible API. The new engine under Vite. |
| Vite | the framework-agnostic dev/build tool | Dev = native ESM, no bundling (see below). Vite 8 (Mar 2026) unified on Rolldown, replacing the old esbuild-dev / Rollup-prod split. The modern default for SPAs. |
| Turbopack | Rust — Webpack's successor | Incremental, Rust-fast, by Vercel for Next.js. The Next-native rival to the Vite/Rolldown stack. |
SWC (Rust) is the analog for transforms — a Babel replacement Next.js uses to compile TS/JSX fast. [swc.rs] The 2026 theme to name: the JS toolchain is consolidating onto one fast Rust core — Oxc powers Rolldown and oxlint (≈50–100× faster than ESLint) and the formatter, so parser/resolver are shared instead of re-implemented per tool. [oxc.rs] [VoidZero]
The 2026 update worth knowing: Vite originally had a “split-brain” setup — esbuild in dev, Rollup in prod — which meant subtle dev/prod differences. Vite 8 (March 2026) unified both onto Rolldown (the Rust bundler on Oxc), so one engine serves dev and builds prod. The honest framing: the native-ESM dev model is still why dev is instant; Rolldown is what makes the production build Rust-fast and removes the two-bundler inconsistency. [oxc.rs]
“Webpack bundles the whole app before serving dev — start scales with app size. Vite serves source over native ESM and transforms only what the browser asks for. And Vite 8 unified dev and prod onto Rolldown, so it's one Rust engine end-to-end instead of esbuild-plus-Rollup.”
You can't shrink what you can't see. The workflow:
webpack-bundle-analyzer / source-map-explorer /
rollup-plugin-visualizer render a treemap of what's in the bundle — spot the surprise 300 KB
lib, duplicate copies of the same dep at different versions, and dead weight. [analyzer]moment (huge, no tree-shaking) →
date-fns/dayjs/native Intl; full lodash → per-method or
native; a chart/animation lib loaded eagerly → lazy-loaded.A one-off bundle diet regresses the moment the next team ships. So you put a size budget in CI that fails
the PR when a bundle crosses a threshold — size-limit, bundlesize, or Lighthouse-CI
resource budgets. Now bundle size is a team discipline with a number, not a heroic quarterly cleanup. [web.dev]
Concept: ship less JS — split by route, tree-shake, prune. Trade-off: over-splitting buys a
request waterfall, and a bundler migration has real cost — so I tune granularity and migrate only on measured
pain. Anchor: “At the platform-scale, the bundle analyzer showed three teams each pulling a different
moment.js version; we swapped to a shared dayjs and added a size-limit CI gate.” Impact:
smaller initial JS → faster INP/LCP on mobile, and the gate stops regressions across many teams. Invite:
“If we were a single small SPA I'd care less about governance and more about route-splitting; the budget matters
because many teams ship into one bundle.”
Pick an answer; instant feedback. Push-back style, like the round.
1. Why is a kilobyte of JavaScript more expensive than a kilobyte of image?
2. What creates a separate chunk (a code-split point) for the bundler?
3. Why can ESM be tree-shaken but CommonJS effectively can't?
4. A teammate sets "sideEffects": false and suddenly the app loads with no
styles. What happened, and the fix?
scn: the app does import './app.css' in several modules.
5. Your colleague wants to lazy-load every component to “make the bundle tiny.” Your pushback?
6. Why does Vite's dev server start almost instantly while Webpack's slows as the app grows?
7. The bundle is 200 KB bigger than expected. import _ from 'lodash' appears in
one util. Best move?
scn: they only use debounce and get.
8. Most Lead answer to “how do you keep bundle size down across many teams?”
9. Why split node_modules into a separate vendor chunk if it doesn't
reduce total bytes?
scn: a teammate says "it's the same size either way, so why bother?"
10. An interviewer asks "what's changed in the bundler world lately?" Strongest answer?
11. You route-split, and now the first navigation into a route shows a spinner — users say the app feels slower. Best response?
scn: an interviewer pushing on a real consequence of your last answer.
0 / 11 answered