ES Modules

4 min read source

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": false in package.json.
  • CommonJS interop — dynamic require shape 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 for let/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.
  • import is read-only and hoisted — you can’t conditionally import x with an if; use await import().
  • .json imports 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 exports shape; import pkg from vs import { x } from can 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: false and avoid export *.”

Cross-references

Further reading