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 Router's lazy() uses dynamic import for route-level code splitting; React 19 Suspense integrates the returned promise.Angular's loadChildren/lazy routes and @defer use dynamic imports to split NgModules and standalone components into chunks.defineAsyncComponent(() => import('./X.vue')) and Vue Router's lazy routes are built on dynamic import.SvelteKit's +page/+layout imports and vite's build split chunks at dynamic import boundaries automatically.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.
import { renderChart } from './chart.js'; // ❌ OLD: chart.js is part of the initial bundleconst { renderChart } = await import('./chart.js'); // ✅ NEW: separate runtime chunk, loaded lazilyInterview 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).
Context
Interviewers want the runtime mechanics — static edges resolved at link time vs a runtime resolution request — and how bundlers translate that into chunks.
The mechanical answer
Static imports are resolved at module link time: the engine builds the dependency edges while parsing (ModuleRequests) and instantiates the whole reachable graph before evaluation begins. Dynamic import() is a runtime operation: when executed, it calls the host's module resolution (HostResolveImportedModule) to fetch and parse a new module record, instantiates it, evaluates it if not already evaluated, and resolves the returned promise with the module namespace. At the engine level this is a separate module request added to the module map; at the bundler level, import() boundaries become chunk split points — the bundler emits the dynamically-imported subtree as its own file loaded via script/fetch on demand. That split is exactly what code splitting relies on.
Trap
The naive answer is 'dynamic import is just a lazy static import'. It is a fundamentally runtime operation with a promise, and it cannot be hoisted — using the imported namespace at module top level is impossible because the import hasn't resolved yet. Another trap: assuming bundlers split automatically at every import() — they do, but you still control boundaries; splitting too finely creates network waterfall overhead, and too coarsely defeats lazy loading. Candidates who describe the module-map resolution and chunk boundary semantics show real understanding.