ES Modules
TL;DR
ESM is the standard module system: import/export, statically analyzable, with live bindings and asynchronous loading. Static imports are hoisted and resolved before execution; dynamic import() returns a promise and enables code splitting and conditional loading. Modules are always strict, have their own top-level scope (top-level this is undefined), and execute once (cached). The senior contrasts: ESM vs CommonJS (require), live bindings vs value copies, and why static structure is what makes tree shaking possible.
Interview Q&A
Q: ESM vs CommonJS — the real differences?
A:
ESM (import) |
CommonJS (require) |
|
|---|---|---|
| Loading | async, statically analyzed | sync, dynamic |
| Bindings | live (read-only views of exports) | copy of the value at require time |
this at top level |
undefined |
module.exports |
| Resolved | before execution (hoisted) | at the point of the require call |
| Tree-shakeable | yes (static structure) | not reliably |
| Conditional load | await import() |
require() anywhere |
Q: What are “live bindings”?
A: An import is a view into the exporting module’s variable, not a snapshot. If the exporter mutates it, importers see the new value:
// counter.js
export let count = 0;
export function inc() { count++; }
// main.js
import { count, inc } from "./counter.js";
console.log(count); // 0
inc();
console.log(count); // 1 — live binding, not a copy
With CommonJS, const { count } = require("./counter") would have copied 0 and never updated. You also can’t assign to an imported binding — it’s read-only on the import side.
Q: Static import vs dynamic import()?
A: Static imports are declarations — hoisted, run before any module code, only at the top level. Dynamic import() is an expression returning a promise, allowed anywhere:
// lazy-load a heavy module only when needed
button.addEventListener("click", async () => {
const { renderChart } = await import("./chart.js"); // separate chunk
renderChart();
});
Dynamic import is the primitive behind route-based code splitting and React.lazy. See ../09_build_tools/.
Q: What is top-level await?
A: In a module you can await at the top level; the module becomes async and modules importing it wait for it to finish before their own code runs.
// config.js
export const config = await fetch("/config.json").then(r => r.json());
Powerful for setup, but it blocks the import graph — a slow top-level await delays every dependent module. Use sparingly; prefer lazy initialization for anything slow.
Q: How does tree shaking work, and what defeats it?
A: Because ESM imports/exports are static, bundlers build a dependency graph and drop exports that are never imported (dead-code elimination). It’s defeated by:
- Side effects — top-level code that does work on import. Mark side-effect-free packages with
"sideEffects": falseinpackage.json. - CommonJS interop — dynamic
requireshape can’t be statically pruned. - Re-export barrels that pull in everything (
export * from) — importing one symbol can drag the whole barrel. See ../12_project_structure/.
Q: How do you interop CJS and ESM?
A: ESM can import a CJS module — its module.exports becomes the default export, and named exports are best-effort detected. CJS cannot require() an ESM module synchronously (ESM is async); it must use dynamic import(). The package.json "exports" field with "import"/"require" conditions lets a package ship both — but shipping two copies risks the dual-package hazard (two instances of the same module with separate state).
Q: How are modules resolved and cached?
A: A module is fetched, parsed, and evaluated once; subsequent imports return the cached module namespace. Resolution: browsers need full paths/extensions (or an import map to alias bare specifiers); Node uses package.json "exports"/"main" and the file extension (.mjs = ESM, .cjs = CJS, "type": "module" flips the default for .js).
Gotchas / edge cases
- Circular imports: ESM handles them via live bindings, but if you read an imported binding at module-eval time before the exporter finished, you get
undefined(temporal dead zone forlet/const). Reference imported values inside functions, not at top level. - Bare specifiers in the browser (
import x from "lodash") don’t resolve without an import map or a bundler. importis read-only and hoisted — you can’t conditionallyimport xwith anif; useawait import()..jsonimports need an attribute:import data from "./d.json" with { type: "json" }.- Mixing default and named from a CJS package often surprises — check the package’s actual
exportsshape;import pkg fromvsimport { x } fromcan differ.
What a senior is expected to say
- “ESM is static and async with live bindings; CJS is dynamic and sync with value copies. Static structure is why tree shaking works.”
- “Dynamic
import()returns a promise and powers code splitting /React.lazy.” - “Top-level await blocks the import graph — fine for config, dangerous for anything slow.”
- “Tree shaking dies on side effects and barrel re-exports; flag
sideEffects: falseand avoidexport *.”
Cross-references
- Code splitting and tree shaking in bundlers: ../09_build_tools/
- Barrel-file pitfalls: ../12_project_structure/
import attributesand other ES features: ../13_es_features/- Backend contrast — Python imports: ../../backend/02_python_core/07_python_imports.md
Further reading
- MDN — JavaScript modules: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules
- “ES modules: A cartoon deep-dive” (Lin Clark): https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/
- Node.js — Modules: ECMAScript modules: https://nodejs.org/api/esm.html