Build tooling & bundlers
How a bundler builds the module graph
Starting from your entry point, the bundler parses each file for import and export, resolves every specifier to a real file (relative paths against the importer, bare names like lodash by walking node_modules), and recurses until it has the whole graph. Importing the same file twice yields a single deduped node. That graph is what tree shaking, code splitting, and chunk output are computed from.
Which file a bare import resolves to comes from the dependency's package.json: the modern exports field with conditions (import, require, browser), or the older main and module fields.
Why tree shaking needs ESM, not CommonJS
Tree shaking is dead-code elimination. It works by reading the static structure of ESM import/export: those are top-level, hoisted, and cannot be wrapped in an if or a function, so the bundler knows the full import/export shape without running the code.
Wrong "Tree shaking will drop the unused parts of this CommonJS library." CommonJS require() is a runtime call, the path can be a variable, and module.exports can be assembled while the code runs. The bundler cannot prove which exports go unused, so it keeps all of them. Right reach for the library's ESM build (for example lodash-es instead of lodash) so the graph is analyzable.
One day-to-day trap in webpack: mode:'development' only marks unused exports. Bytes are removed in mode:'production', where Terser deletes what was marked.
Code splitting with dynamic import()
A static import at the top of a file lands in the parent chunk no matter where you write it. To load code on demand, call import('./Heavy'), which returns a promise and becomes its own chunk fetched when that line runs.
import Heavy from "./Heavy"; // static: hoisted into the parent chunk
const mod = await import("./Heavy"); // dynamic: its own chunk, fetched on demand
import(`./locales/${lang}.js`); // variable specifier: bundles every match
Keep the specifier a literal string. A literal splits one clean chunk; a variable or template-string specifier forces the bundler to include every file that could match, because it cannot know the target ahead of time.
Vite's dev server vs webpack's
webpack builds the whole app into a bundle before it can serve a page, so cold start grows with the app. Vite serves your source over native browser ESM and transforms each file only when the browser requests it, so dev start stays fast as the app grows.
Before that, Vite pre-bundles your dependencies once (esbuild through Vite 7, Rolldown in Vite 8) for two reasons: it converts CommonJS and UMD packages to ESM so the browser can load them, and it collapses a package's many internal files into few requests. Importing one helper from lodash-es would otherwise fire hundreds of separate requests; pre-bundling makes it one.
Why ESM tree-shakes and CommonJS doesn't
ESM import/export are statically analyzable: top-level only, hoisted, immutable bindings, strict mode by default. The bundler reads the full import/export shape without executing anything. CommonJS is the opposite. require() is a function call whose argument can be computed (require(cond ? './a' : './b')), it can sit inside a branch, and module.exports can be built at runtime. There is no safe static answer to which exports are unused, so the bundler keeps them all.
webpack has two mechanisms and they differ. optimization.usedExports marks individual exports as used or unused, and Terser removes the unused ones, only in mode:'production' (development merely marks). The package.json sideEffects flag is stronger: it lets webpack drop whole modules that are re-exported but never used, rather than one export at a time.
Barrel files fight this. An index.ts that does export * from a dozen modules means importing one symbol pulls the whole re-export graph into the traversal, which defeats pruning and slows dev-server transforms and Fast Refresh. Prefer deep imports and mark shared packages side-effect-free.
The sideEffects contract and the CSS trap
sideEffects is a contract the bundler acts on. false says every file in the package is safe to drop if unused. An array says only those files have effects, and a glob without a slash is prefixed with **/, so *.css matches every stylesheet.
{ "sideEffects": ["./src/polyfill.js", "*.css"] }
Wrong "Set sideEffects:false everywhere, it only helps." A side effect is any work an import does besides exposing exports: a polyfill mutating globals, a CSS import applying styles, a listener registration. Right if a file imports CSS for effect and you declare the package side-effect-free, the bundler prunes import './styles.css' and the styles vanish. List CSS in the array.
Code-split chunk hazards
Dynamic import() needs a literal specifier to split cleanly; a variable or template-string specifier makes the bundler include every file that could match. webpack magic comments tune delivery: webpackPrefetch emits a prefetch link (fetched during browser idle after the parent, for a likely next navigation), while webpackPreload emits a preload link (fetched in parallel with the parent, for the current one).
The deploy hazard is ChunkLoadError. Hashed chunk filenames plus a long-cached HTML shell let an old client request a chunk your deploy already removed, and the dynamic import rejects at runtime. Mitigate with a retry-on-chunk-error boundary and by keeping recent chunks around across a deploy.
Targets and the tool that ignores browserslist
Two systems people conflate. @babel/preset-env reads Browserslist and compiles a plugin list from your targets; by default it transforms syntax only, so API polyfills need useBuiltIns:'usage'|'entry' plus core-js. Vite's core JS path does not read Browserslist: it uses the esbuild/Oxc build.target, defaulting to baseline-widely-available (features supported across the major browsers for at least 30 months).
Wrong "My .browserslistrc controls the Vite JS output." Only PostCSS reads Browserslist, for your CSS, which is why CSS appears to honor it while JS does not. Right to feed Browserslist into the JS target, use browserslist-to-esbuild, or add @vitejs/plugin-legacy, which routes through Babel and emits a legacy bundle behind <script nomodule>.
Dev/prod parity and shippable source maps
Vite serves native ESM in dev but bundles for production, because deep unbundled import chains cause request waterfalls in the browser. That gap is a real failure mode: a side-effect ordering bug or a CJS interop quirk can surface only in the built artifact, so test the production build rather than trusting the dev server. Through Vite 7 esbuild pre-bundled deps in dev and Rollup built production, which is why "Vite uses esbuild for production" was never accurate; Vite 8 replaces both with one Rust bundler, Rolldown, plus Oxc for transform and minification.
Source maps are the last trap. webpack's source-map and Vite's sourcemap:true emit a full map with a reference comment, which publishes your original source to anyone who opens devtools. For production use hidden-source-map or nosources-source-map (webpack) or sourcemap:'hidden' (Vite), and upload the maps privately to your error tracker.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.