Module Systems, Bundling & Tree Shaking
ESM vs CommonJS
ESM (import/export) |
CommonJS (require/module.exports) |
|
|---|---|---|
| Parsing | Static — imports resolved at parse time | Dynamic — require() runs at runtime |
| Bindings | Live bindings — value updates propagate | Snapshot — copy of the value at call time |
Top-level await |
Supported | Not supported |
| Tree shaking | Enabled (static graph) | Largely impossible (dynamic) |
| Circular deps | Live binding handles cycles gracefully | Partially evaluated module returned |
| Browser native | Yes (<script type="module">) |
No |
| Node.js | .mjs / "type":"module" in package.json |
.cjs / default |
Live bindings vs snapshot
js// counter.mjs (ESM)
export let count = 0;
export function increment() { count++; }
// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1 ← live binding: sees the updated valuejs// counter.js (CJS)
let count = 0;
module.exports = { count, increment: () => count++ };
// main.js
const { count, increment } = require('./counter');
increment();
console.log(count); // 0 ← snapshot: copied at require() timeInterop gotchas
- CJS
module.exportsbecomes the ESM default export when imported from ESM. - Named CJS exports only work if the bundler does static analysis on
exports.foo = .... __esModule: trueflag on a CJS export signals "treat default as the real default".require()cannot import ESM in Node.js — use dynamicimport()instead.
Module Resolution
Node resolution algorithm (CJS)
- Exact file match (
./utils→./utils.js→./utils/index.js). node_moduleslookup: walk up directory tree until root.- Check
package.jsonmainfield for packages.
ESM / bundler resolution
Bundlers also respect:
exportsfield inpackage.json(supercedesmain— conditional exports per environment).importsfield — package-internal aliases.browserfield — browser-specific entry point.
json{
"exports": {
".": {
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js",
"types": "./dist/index.d.ts"
}
}
}TypeScript moduleResolution modes
| Mode | Follows |
|---|---|
node |
Classic Node CJS algorithm |
node16 / nodenext |
Node ESM algorithm — requires explicit .js extensions |
bundler |
Bundler-style (no extensions required, respects exports) |
Tree Shaking
Tree shaking = dead code elimination based on static import analysis. The bundler builds a module graph, marks exported symbols as "used" or "unused", and drops unused ones.
Requirements:
- ESM syntax (static
import/export). - No side effects on unused imports (or
"sideEffects": falseinpackage.json). - Bundler in production mode (Rollup, esbuild, Vite, webpack with
optimization.usedExports: true).
js// utils.js — exporting three functions
export function used() { return 1; }
export function alsoUsed() { return 2; }
export function neverImported() { return 3; }
// main.js
import { used, alsoUsed } from './utils';
// After tree shaking: neverImported is dropped from the bundlesideEffects flag
json// package.json
{
"sideEffects": false // entire package is side-effect free
}
{
"sideEffects": ["*.css", "./src/polyfills.js"] // only these have side effects
}Without this, bundlers assume import './styles.css' has side effects and keep it even if nothing from it is used.
What breaks tree shaking
- Dynamic access:
obj[key]— bundler can't know which key at build time. - Re-exports via CJS:
module.exports = { ...require('./a'), ...require('./b') }. - Object spread of module:
const all = { ...moduleExports }— forces all exports in. - Side-effectful imports:
import 'reflect-metadata'(Angular) must not be tree-shaken. - Class methods: bundlers can't remove individual methods from a class (they're on the prototype, technically a side effect).
Code Splitting
Entry-point splitting
Each entry point becomes a separate chunk. Shared code is automatically extracted into shared chunks (splitChunks in webpack, manualChunks in Rollup/Vite).
Dynamic import()
js// The module is split into a separate chunk, loaded on demand
const { heavyCalc } = await import('./heavy');Bundlers emit a separate JS file and a runtime that fetches it via <script> injection when import() is called.
Route-based splitting (Next.js / React Router)
jsxconst Dashboard = React.lazy(() => import('./Dashboard'));
// Next.js App Router splits automatically per page/layoutPreloading & prefetching
jsimport(/* webpackPrefetch: true */ './LargeModal');
// Emits: <link rel="prefetch" href="large-modal.chunk.js">
import(/* webpackPreload: true */ './CriticalChunk');
// Emits: <link rel="preload" href="critical.chunk.js">prefetch: downloaded during idle time, for future navigation.
preload: downloaded in parallel with current chunk, for current navigation.
Chunk Hashing & Long-term Caching
Bundlers append a content hash to filenames (main.[hash].js). The hash changes only when the file's content changes.
Strategy:
- HTML: no-cache (
Cache-Control: no-store) - JS/CSS chunks with hash: immutable (
Cache-Control: max-age=31536000, immutable) - Runtime chunk: separate file, short cache (changes when chunk graph changes)
Splitting for better cache hits:
app.[hash].js ← changes on every code change
vendor.[hash].js ← only changes when dependencies update (rare)
runtime.[hash].js ← tiny, changes when chunk manifest changesIf vendor and app are bundled together, a one-line bug fix busts the entire cache including lodash/react.
Interview Q&A
Q: Why can't CJS be tree-shaken?
require() executes at runtime — the bundler doesn't know at build time which exports will be accessed (e.g., const key = getKey(); module[key]()). ESM's static import declarations are analyzable at parse time, giving the bundler a complete, known dependency graph.
Q: What is a live binding and why does it matter for HMR? An ESM live binding means the imported name is a reference to the exporting module's slot, not a copy. When the exporter updates the value, importers see the change immediately. HMR exploits this: replacing a module updates the live binding, so consumers automatically get the new value without re-importing.
Q: What does "type": "module" in package.json do?
Tells Node.js to treat all .js files in that package as ESM. Without it, .js is CJS. You can override per-file with .mjs (always ESM) or .cjs (always CJS).
Q: A library author says "just import the function you need". Why doesn't that always reduce bundle size?
Tree shaking only works if: (1) the library uses ESM, (2) the library's package.json has "sideEffects": false, and (3) there are no dynamic accesses inside the library. Many older libraries ship CJS only, or have barrel files that import everything, defeating tree shaking.
Q: What is a barrel file and why is it a problem?
A barrel file re-exports everything from a directory: export * from './ComponentA'; export * from './ComponentB'; .... Even if you only use ComponentA, a bundler without perfect tree shaking may include all re-exported modules. Solution: import directly (import { X } from './lib/X') or use a bundler plugin that handles barrels (e.g., babel-plugin-transform-imports).
Q: How does import() affect the initial bundle?
import() creates a split point — the dynamically imported module and its unique dependencies are emitted as a separate chunk file. The initial bundle only includes the bootstrap code to fetch that chunk. This reduces the initial JS parse/execute cost at the cost of a network round-trip when the dynamic import fires.