Frontend / JavaScript core / 08_es_modules.md

ES Modules

Updated 4 interview angles 5 min read source
On this page11
  1. ESM vs CommonJS — the real differences?
  2. What are “live bindings”?
  3. Static import vs dynamic import()?
  4. What is top-level await?
  5. How does tree shaking work, and what defeats it?
  6. How do you interop CJS and ESM?
  7. How are modules resolved and cached?
  8. Gotchas
  9. Related
  10. Further reading
  11. Interview angle

ES Modules

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.

ESM vs CommonJS — the real differences?

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

What are “live bindings”?

An import is a view into the exporting module’s variable, not a snapshot. If the exporter mutates it, importers see the new value:

js
// 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.

Static import vs dynamic import()?

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:

js
// lazy-load a heavy module only when needed
button.addEventListener("click", async () => {
  // separate chunk
  const { renderChart } = await import("./chart.js");
  renderChart();
});

Dynamic import is the primitive behind route-based code splitting and React.lazy. See Build Tools — Senior Interview Prep.

What is top-level await?

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.

js
// 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.

How does tree shaking work, and what defeats it?

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 Project Structure — Senior Interview Prep.

How do you interop CJS and ESM?

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).

How are modules resolved and cached?

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

  • 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.

Further reading

Interview angle 4

  • “ESM or CommonJS - what actually differs?” - ESM is statically analysable and asynchronous with live bindings; CJS is dynamic and synchronous and copies values at import time. A live binding means an imported let reflects a later reassignment in the exporting module, which a CJS copy never does.
    • Follow-up: “Why does that matter in practice?” - the static structure is what makes tree shaking possible at all. A bundler can prove which exports are unused only because the import graph is knowable without running the code, and a require() inside an if destroys that.
  • “How does code splitting work?” - dynamic import(), which returns a promise and becomes its own chunk. It is what React.lazy and route-level splitting are built on, and it is the only import form allowed inside a conditional.
  • “When is top-level await a problem?” - when it is slow. It blocks every module that imports yours, and those blocks compose, so a fetch at the top of a widely-imported module delays the whole graph. Fine for reading config at startup, wrong for anything on a network.
  • “Your bundle did not shrink after tree shaking. Why?” - usually side effects the bundler cannot prove absent, or barrel files. export * from an index re-exports everything and pulls the lot in, and any module-level code with an observable effect has to be kept. Mark packages "sideEffects": false and import from the specific path rather than the barrel.