Under The Hood
frontend javascript ES2015+ modules, HTML/Node ESM

ESM Static Linking, Cyclic Graph Evaluation, and import() Runtime Chunks

Last updated
Prerequisites:
ES module syntax (import/export)
Understanding of the module graph and bundlers
  • esm
  • modules
  • dynamic-import
  • cyclic-dependencies
  • bundling
  • import

Read at your depth

The practical view

ES modules are statically analyzable: import statements are hoisted and resolved before any code runs, which is what enables tree-shaking. Static imports load the whole graph eagerly; import('./mod.js') loads a module on demand at runtime and returns a promise of its namespace. Use dynamic import for code-splitting (load heavy features only when needed), lazy routes, and conditional loading: const { format } = await import('./formatter.js');. Named exports of a module are live bindings — importing a variable gives you a live link to the module's value, not a copy. Cyclic imports work in ESM because the graph is linked first, then evaluated depth-first with live bindings.

The same idea in other frameworks

react equivalent
React Router's lazy() uses dynamic import for route-level code splitting; React 19 Suspense integrates the returned promise.

Legacy vs modern

Eager static import vs on-demand dynamic import for a heavy module

A static import pulls the heavy module into the initial bundle; dynamic import() defers loading to a runtime chunk, shrinking the startup payload.

before → after
Static import of a heavy module
import { renderChart } from './chart.js'; // ❌ OLD: chart.js is part of the initial bundle
Dynamic import on demand
const { renderChart } = await import('./chart.js'); // ✅ NEW: separate runtime chunk, loaded lazily

Interview gotchas

Context

This tests the crucial distinction between linking and evaluation — a concept that separates people who understand ESM from those who think it's just 'better require'.

The mechanical answer

ESM decouples linking from evaluation. First, the linker resolves the entire graph and instantiates every module's environment, binding each module's exported names to shared slots — this happens before any module body executes. Then evaluation runs in post-order (dependencies first). When a cycle is encountered, the traversal sees the module is already being evaluated (an 'in-progress' marker) and skips re-running it. Because exports are live bindings to slots, a module that imports a value from a not-yet-initialized module holds a binding that will hold the real value once that module finishes evaluating — so calling the imported function too early fails, but reading it later works. This is why cycles with function declarations are fine but cycles that use a binding at module top-level during evaluation can observe undefined.

Trap

The naive answer is 'cycles are a bug you should just avoid'. They occur naturally in real graphs and ESM handles them by design — but only thanks to live bindings and the in-progress guard. Another trap: assuming imports are copies — they are live bindings, so mutating an exported value in one module is visible in all importers, and using a binding before the exporting module initialized yields undefined (the classic TDZ gotcha for cyclic imports).

Under The Hood — a multi-depth technical interview hub.

Third-party licenses Press ⌘ K to search.