ESM: Linking, Instantiation, Evaluation Phases and Circular Dependencies
import is not an ES6 version of require — they are two fundamentally different module systems. ESM completes a static analysis of the dependency graph before any code executes. All bindings exist at the "linking" phase before they have values. This three-phase design explains why live bindings work, and why some variables are undefined in circular dependencies.
🔹 Level 1 · What You Need to Know
import vs. require: Fundamental Differences
| Dimension | ESM (import) |
CommonJS (require) |
|---|---|---|
| Resolution time | Static (before code executes) | Dynamic (at runtime) |
| Export content | Live binding (changes to exported variable are visible) | Value copy (snapshot at import time) |
| Top-level await | Supported (ES2022) | Not supported |
| Circular dependencies | Supported (with limitations) | Supported (returns incomplete object) |
| Async loading | Built-in (import()) |
Requires manual handling |
| Spec source | ECMAScript + HTML spec | Node.js CommonJS spec |
ESM Basic Syntax
// Named exports
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export class Vector { /* ... */ }
// Default export
export default function main() { /* ... */ }
// Re-exports
export { add, PI } from './math.js';
export * from './utils.js';
export * as utils from './utils.js'; // namespace re-export
// Imports
import defaultExport from './module.js';
import { named1, named2 } from './module.js';
import * as namespace from './module.js';
import defaultExport, { named } from './module.js';
// Dynamic import (returns Promise)
const module = await import('./lazy.js');
Live Binding: The Key Difference from CommonJS
// counter.js (ESM)
export let count = 0;
export function increment() { count++; }
// main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 (live binding — sees the updated value)
// ────────────────────────────────────────
// counter.js (CommonJS)
let count = 0;
function increment() { count++; }
module.exports = { count, increment };
// main.js
const { count, increment } = require('./counter.js');
console.log(count); // 0
increment();
console.log(count); // 0 (value copy — count is a snapshot, not linked)
Top-Level Await
ESM supports await at the module top level (no need to wrap in an async function):
// config.js (ESM)
const config = await fetch('/api/config').then(r => r.json());
export const API_KEY = config.key;
// Modules importing config.js must wait for its top-level await
// to complete before their own Evaluation phase begins
🔸 Level 2 · How It Actually Works
The Three Phases in Full
┌─────────────────────────────────────────────────────────────────┐
│ ESM Module Loading — Three Phases │
│ │
│ Phase 1: Linking │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ • Statically parse all import/export declarations │ │
│ │ • Build the complete module dependency graph │ │
│ │ • Create binding slots (memory locations) for exports │ │
│ │ • Detect circular dependencies │ │
│ │ • Bindings exist but have no values yet (in TDZ) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ Phase 2: Instantiation │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ • Create a Module Environment Record for each module │ │
│ │ • Link export names to Environment Record slots │ │
│ │ • Link imported names to the exporting module's slots │ │
│ │ • let/const enter TDZ (Temporal Dead Zone) │ │
│ │ • Function declarations are hoisted into bindings │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ Phase 3: Evaluation │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ • Execute module code in depth-first order │ │
│ │ • Each module evaluates only once (cached thereafter) │ │
│ │ • Execution fills the export slots with values │ │
│ │ • Top-level await pauses evaluation (waits for Promise)│ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
How Live Binding Is Implemented
The key to live binding is that ESM import imports not a value but a reference to the exporting variable's memory slot (binding slot):
Module A's memory layout (after linking):
A's Module Environment Record:
┌──────────────────────────────────────────┐
│ Binding: count → [memory slot 0x1234] │
│ [0x1234] = 0 (initial value) │
└──────────────────────────────────────────┘
When B imports from A:
import { count } from 'A'
┌──────────────────────────────────────────┐
│ B's count → points to A's [0x1234] │
│ (not a copy — the same slot) │
└──────────────────────────────────────────┘
After A executes count++:
[0x1234] = 1
B reads count:
B.count → A's [0x1234] → 1 (latest value)
Circular Dependencies: How ESM Handles Them
Module A imports Module B; Module B imports Module A:
// a.js
import { b } from './b.js';
export const a = 'value-a';
export function useB() { return b; }
// b.js
import { a } from './a.js';
export const b = 'value-b';
export function useA() { return a; }
Linking phase (building the dependency graph):
Dependency graph:
entry
│
▼
a.js ──→ b.js ──→ a.js (circular!)
↑ already exists — stop recursion
The linking algorithm uses DFS + "currently linking" flag to prevent infinite recursion:
- Start linking a.js; mark a.js as "linking"
- a.js depends on b.js; start linking b.js
- b.js depends on a.js; a.js is already "linking" (circular dependency detected)
- Stop: a.js's binding slots are created but not yet filled with values
- b.js linking completes
- a.js linking completes
Evaluation phase (DFS execution):
Evaluation order (DFS post-order):
1. Evaluate b.js (a.js not yet evaluated → a's binding is TDZ)
2. Evaluate a.js
// b.js evaluates first:
import { a } from './a.js'; // a has no value yet (a.js not evaluated)
export const b = 'value-b';
export function useA() { return a; } // captures live binding — deferred access
// b.js done. b = 'value-b'
// a.js evaluates next:
import { b } from './b.js'; // b already has value ('value-b')
export const a = 'value-a';
export function useB() { return b; }
When you access determines what you see:
// Accessing at module top level (during initialization):
// In b.js top level:
console.log(a); // undefined or TDZ! (a.js not yet evaluated)
// Accessing inside a function (deferred access — a.js is evaluated by call time):
useA(); // 'value-a' (a.js has already fully evaluated when useA is called)
Function declarations vs. variable declarations:
// a.js
import { funcB } from './b.js';
export function funcA() { return 'from A'; } // function decl (hoisted)
export const varA = 'varA'; // const (NOT hoisted)
// b.js (evaluates before a.js due to cycle)
import { funcA, varA } from './a.js';
console.log(funcA()); // 'from A' — function declarations are hoisted at link time
console.log(varA); // ReferenceError (TDZ) — const is not initialized yet
DFS Evaluation Order Illustration
Module dependency graph:
main.js
├── utils.js
│ └── helpers.js
└── api.js
└── utils.js (already visited)
DFS evaluation order (post-order):
1. helpers.js (leaf node)
2. utils.js (helpers.js evaluated)
3. api.js (utils.js evaluated)
4. main.js (all dependencies evaluated)
Each module evaluates only once. Subsequent imports use the cached module instance.
How Top-Level Await Affects Module Loading Order
Module dependency graph:
main.js
├── slow.js (has top-level await, takes 2 seconds)
└── fast.js (no top-level await)
Loading timeline:
0ms: Linking phase completes (all modules)
│
├── Begin evaluating fast.js (completes immediately)
│
└── Begin evaluating slow.js
│ await fetch('/slow-api')...
│ waiting... (fast.js can proceed in parallel)
│
2000ms: slow.js Promise resolves
│ slow.js evaluation completes
│
2000ms: main.js begins evaluation (all dependencies ready)
Key: Top-level await blocks only the modules that depend on the awaiting module. Sibling modules (those that don't depend on slow.js) can load and evaluate in parallel. This is completely different from CommonJS require (always synchronous).
🔺 Level 3 · How the Spec Defines It
Spec §16.2: Modules
The ECMAScript spec Chapter 16 defines the module system. Key internal slots of a Module Record:
Source Text Module Record (ESM modules):
[[Realm]] — associated Realm
[[Environment]] — Module Environment Record (after evaluation)
[[Namespace]] — module namespace object (lazy-created)
[[ECMAScriptCode]] — parsed module AST
[[RequestedModules]] — static dependencies (from import statements)
[[ImportEntries]] — list of import records
[[LocalExportEntries]] — local export records
[[IndirectExportEntries]] — indirect exports (re-exports)
[[StarExportEntries]] — export * entries
[[Status]] — unlinked | linking | linked | evaluating | evaluated
[[DFSIndex]] — DFS traversal index (circular dep detection)
[[DFSAncestorIndex]] — DFS ancestor index (Tarjan's algorithm)
[[CycleRoot]] — root of circular dependency group
[[HasTLA]] — whether module has top-level await
[[AsyncEvaluation]] — whether in async evaluation
[[TopLevelCapability]] — PromiseCapability for top-level await
[[AsyncParentModules]] — parent modules waiting for this module
[[PendingAsyncDependencies]] — count of pending async dependencies
InnerModuleLinking Algorithm
Spec §16.2.1.5.1 defines InnerModuleLinking (DFS traversal):
InnerModuleLinking ( module, stack, index )
- If module is not a Source Text Module Record: a. Perform ? module.Link(). b. Return index.
- If module.[[Status]] is linking, linked, evaluating, or evaluated: a. Return index. ← already linked or in progress: skip (handles circular deps)
- Assert: module.[[Status]] is unlinked.
- Set module.[[Status]] to linking.
- Set module.[[DFSIndex]] to index.
- Set module.[[DFSAncestorIndex]] to index.
- Set index to index + 1.
- Append module to stack.
- For each required module reqModule in module.[[RequestedModules]]: a. Let requiredModule be GetImportedModule(module, reqModule.[[Specifier]]). b. Set index to ? InnerModuleLinking(requiredModule, stack, index). c. Set module.[[DFSAncestorIndex]] to min(module.[[DFSAncestorIndex]], requiredModule.[[DFSAncestorIndex]]).
- Perform ? module.InitializeEnvironment(). ← creates Module Environment, links bindings
- Assert: module occurs exactly once in stack.
- Assert: module.[[DFSAncestorIndex]] ≤ module.[[DFSIndex]].
- If module.[[DFSAncestorIndex]] = module.[[DFSIndex]]: a. Repeat: i. Let requiredModule be the last element of stack; remove it. ii. Set requiredModule.[[Status]] to linked. iii. If requiredModule is module, stop.
- Return index.
Step 13 implements Tarjan's Strongly Connected Components algorithm: it identifies the "root" of a circular dependency group and marks the entire SCC as linked in one pass.
InnerModuleEvaluation Algorithm
Spec §16.2.1.5.2:
- If module.[[Status]] is evaluating or evaluated: a. If no evaluation error, return index. ← already evaluated: skip b. Otherwise: throw module.[[EvaluationError]].
- ...(DFS setup similar to linking)
- For each required module reqModule: ← recursively evaluate dependencies first a. Set index to ? InnerModuleEvaluation(reqModule, stack, index).
- If module.[[HasTLA]] is true: ← top-level await handling a. Perform ExecuteAsyncModule(module).
- Else: ← synchronous module a. Perform ? module.ExecuteModule().
- ...(mark evaluated, DFS cleanup)
ResolveExport Algorithm
import { foo } from './a.js' calls ResolveExport during linking to trace re-export chains:
ResolveExport(exportName, resolveSet):
1. Check if (this, exportName) is already in resolveSet → return null (cycle)
2. Add (this, exportName) to resolveSet
3. Search [[LocalExportEntries]] for exportName
→ Found: return { [[Module]]: this, [[BindingName]]: localName }
4. Search [[IndirectExportEntries]] (re-exports)
→ Trace to source module, recursively call ResolveExport
5. Search [[StarExportEntries]] (export *)
→ Collect all matches across all star sources
6. If multiple matches: throw SyntaxError (ambiguous export)
7. Return result or null (not found)
💎 Level 4 · Edge Cases and Traps
Trap 1: Imported Live Bindings Are Read-Only
// math.js
export let counter = 0;
export const increment = () => counter++;
// main.js
import { counter, increment } from './math.js';
console.log(counter); // 0
increment();
console.log(counter); // 1 (live binding — automatically updated)
// Wrong: cannot reassign an imported live binding
counter = 5; // TypeError: Assignment to constant variable.
// Imported bindings are read-only!
// Only the exporting module (math.js) can write to the slot.
// Correct: use an exported function to update
increment(); // let math.js modify counter internally
Deep reason: import { counter } creates a read-only "view" (live binding) pointing to math.js's memory slot. You can see value changes, but you cannot write to that slot — writes can only come from the module that owns it (math.js).
Trap 2: CommonJS Circular Dependencies Return Incomplete Objects
// a.cjs
const b = require('./b.cjs'); // b.cjs starts executing
console.log('a: b.value =', b.value); // undefined!
exports.value = 'a-value';
// b.cjs
const a = require('./a.cjs'); // a.cjs is executing — returns current (incomplete) exports
console.log('b: a.value =', a.value); // undefined!
exports.value = 'b-value';
// Execution order:
// 1. Start executing a.cjs; mark as loading
// 2. require('./b.cjs'): start executing b.cjs
// 3. require('./a.cjs'): a.cjs is loading — return current exports (empty object {})
// 4. b.cjs finishes: exports.value = 'b-value'
// 5. Back in a.cjs: b's exports are now complete ({value: 'b-value'})
// 6. a.cjs finishes: exports.value = 'a-value'
// Output: b: a.value = undefined → a: b.value = b-value
CommonJS circular dependencies return a partial exports object (populated only up to the current execution point). ESM live bindings always reflect the final value, but evaluation order determines initial visibility.
Trap 3: TDZ Triggered by ESM Circular Dependencies
// a.mjs
import { b } from './b.mjs';
console.log('in a, b =', b); // b.mjs already evaluated, b = 'value-b'
export const a = 'value-a';
// b.mjs
import { a } from './a.mjs';
// a.mjs has NOT been evaluated yet!
console.log('in b, a =', a);
// If a is declared with const/let: TDZ (ReferenceError) in V8
// Some engines may return undefined
export const b = 'value-b';
Safe circular dependency pattern: use only function declarations (they are hoisted); never directly access the other module's exported variables at the top level.
// a.mjs (safe pattern)
import { getB } from './b.mjs';
export function getA() { return 'value-a'; }
export function useB() { return getB(); } // deferred access — called after both modules evaluate
Trap 4: import.meta.url for Relative Path Calculation
// In Node.js ESM, __dirname and __filename do not exist
// Use import.meta.url instead:
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const configPath = join(__dirname, '../../config.json');
// In browsers:
const imageUrl = new URL('./assets/image.png', import.meta.url);
import.meta is a module-private object provided by the host:
- Node.js:
import.meta.url(file URL),import.meta.resolve(specifier) - Browsers:
import.meta.url(module URL) - Vite/webpack: extend with
import.meta.envand other custom properties
Trap 5: Top-Level Await — Blocking vs. Parallel Loading
Module dependency graph:
main.js
├── slow.js (top-level await, takes 2 seconds)
└── fast.js (no top-level await)
Timeline:
0ms: Linking completes (all modules)
│
├── Evaluate fast.js — completes immediately
│
└── Evaluate slow.js
│ await fetch('/slow-api')...
│ (fast.js has already completed in parallel)
│
2000ms: slow.js Promise resolves; slow.js evaluation completes
│
2000ms: main.js begins evaluation
Key: Top-level await blocks only modules that depend on the awaiting module. Sibling modules that do not depend on slow.js evaluate in parallel. This is fundamentally different from CommonJS require (always synchronous and blocking).
Practical advice: Top-level await is appropriate for one-time initialization (loading config, database connection). Avoid it in widely-imported utility modules — it will delay the entire application's startup.
Summary
- ESM module loading has three phases: Linking (static dependency analysis, create binding slots) → Instantiation (connect import names to export slots) → Evaluation (DFS execution, fill slots with values).
import { x }imports a live binding (a read-only view pointing to the exporting module's memory slot) — not a value copy. All importers immediately see any value change made by the exporter.- In ESM circular dependencies, when a module evaluated first accesses
const/letexports from a module not yet evaluated, it encounters TDZ. Using function declarations (hoisted at link time) safely defers access in circular dependency scenarios. - CommonJS circular dependencies return a partial exports object (populated only to the current execution point), which is completely different from ESM live binding behavior.
- Top-level
awaitblocks only modules that depend on it; sibling modules (those that don't) can evaluate in parallel. This is unlike CommonJS's synchronousrequire.