skip to content

In a native ES module graph, main.js imports './a.js'; a.js starts with `import { fromB } from './b.js'` and then declares `export const fromA = 'A'`; b.js starts with `import { fromA } from './a.js'` and logs fromA at its top level. What happens when this runs, and why?

level: middleimportance: should knowfreq 45%

answer

  1. two phases: link, then evaluate
  2. bindings created before any code runs
  3. deeper module of the cycle runs first
  4. uninitialized const, not missing export
  5. function declarations escape the trap

basics

~20 s

b.js evaluates first and throws ReferenceError: Cannot access 'fromA' before initialization. Linking already created the binding for a.js's const, but a.js's body has not run yet, so the binding is still in its temporal dead zone.

solid answer

~40 s

ES module loading has two phases. Linking runs first over the whole graph: every module record is created and every import is wired to the exporting module's binding, so the identifier `fromA` exists in `b.js` before any code runs. Then evaluation walks the graph depth-first from the entry — `main.js` reaches `a.js`, which reaches `b.js`, whose edge back to `a.js` points at a module already being evaluated and is therefore skipped. So `b.js`'s body runs *first*, and its top-level read of `fromA` hits a `const` binding that is created but not yet initialized: `ReferenceError: Cannot access 'fromA' before initialization`. The same cycle would be harmless if `a.js` exported a hoisted `function` declaration, because function declarations are initialized during linking, or if `b.js` only read `fromA` inside a function called later.

code

javascript · 12 lines
javascript
// main.js
import './a.js';

// a.js
import { fromB } from './b.js';
export const fromA = 'A';
console.log('a body, fromB =', fromB);

// b.js
import { fromA } from './a.js';
console.log('b body, fromA =', fromA); // ReferenceError: Cannot access 'fromA' before initialization
export const fromB = 'B';

go deeper

for a junior

Recognise the message 'Cannot access X before initialization' and know it means a value was read before its module's body ran — not that the file or the export is missing.

for a middle

Walk the phases out loud: linking creates all bindings, evaluation runs bodies depth-first, the back edge is skipped, so the deeper cyclic module executes first and hits a temporal dead zone.

for a senior

Explain why the throw is deterministic for a given entry point yet moves when the entry changes, and show the restructuring that makes the cross-module read happen inside a function rather than a module body.

for a principal

Be ready to argue whether the ESM behaviour beats the CommonJS one — a loud failure at a predictable point versus an undefined leaking downstream — and what that implies for sequencing a migration.

## The two phases that make this predictable An ES module is not loaded and run in one pass. The loader first constructs the graph (fetch and parse every module reachable from the entry), then **links** it, then **evaluates** it. Linking is the phase that matters here. Because `import` and `export` are static syntax, the engine knows every module's imports and exports without running a line of code. During linking it creates each module's environment and its bindings, and connects every import to the *binding* in the exporting module — not to a copied value. That is why `fromA` is a real, resolvable identifier inside `b.js` before anything has executed. Creating a binding is not the same as initializing it. A `const`, `let`, or `class` binding is created in an **uninitialized** state; reading it before the declaration executes is the temporal dead zone, and it throws `ReferenceError: Cannot access 'x' before initialization`. Only when the declaration statement actually runs does the binding become readable. ## Evaluation order in a cycle Evaluation is a depth-first walk from the entry module, and each module is evaluated *after* its dependencies. In a cycle that rule cannot be satisfied for both members, so the algorithm breaks the tie: when the walk reaches a module that is already on the stack (already being evaluated), it does not recurse — it skips that edge and continues. For `main.js → a.js → b.js → a.js`: 1. `main.js` begins; its dependency `a.js` is visited. 2. `a.js` begins; its dependency `b.js` is visited. 3. `b.js`'s dependency `a.js` is already in progress, so the edge is skipped. 4. **`b.js`'s body runs to completion first.** Its `console.log(fromA)` throws, because `a.js` has not executed its `const` declaration yet. If `b.js` had not read `fromA` at the top level, its body would finish, then `a.js`'s body would run, and `a.js`'s own read of `fromB` would see `'B'` — the deeper module finished, so its exports are complete. Cycles are only dangerous in one direction: the module that evaluates *first* is the one that can see an unfinished partner. ```js // b.js — evaluates first because of the cycle import { fromA } from './a.js'; console.log(fromA); // ReferenceError: Cannot access 'fromA' before initialization export const fromB = 'B'; ``` ## Which declaration forms survive the cycle Not every export behaves the same during linking: - **Function declarations** are hoisted *and initialized* when the module environment is set up, before evaluation. A cyclic partner can call an imported function declaration from its top level and it works. - **`var`** bindings are initialized to `undefined` at the same point, so reading one early yields `undefined` rather than throwing — quiet, and usually worse than the throw. - **`const`, `let`, and `class`** are uninitialized until their declaration executes, so an early read throws. `class` is a frequent victim: a cyclic `extends` evaluates the superclass expression at class-definition time and blows up immediately. ## Why the error moves when the entry point changes Evaluation order is decided by the walk, and the walk starts at the entry module. Make `b.js` the entry and the order flips: `a.js` evaluates first, its top-level read of `fromB` throws instead, and the module you "fixed" is now the broken one. This is why the same cycle can pass a focused unit test that imports one file and fail in the application, and why chasing the specific throwing line is the wrong instinct — the graph shape is the bug. ## The standard rescue Move the cross-import read out of module-body position. Imported bindings are live, so a function that reads `fromA` when it is *called* sees the initialized value, because by then both bodies have finished: ```js // b.js — no top-level read, so the cycle is inert at load time import { fromA } from './a.js'; export const describe = () => `from a: ${fromA}`; ``` That makes the program run, but it leaves the cycle in the graph and the next top-level read reintroduces the failure. The durable fix is to remove the loop — usually by extracting the value both modules need into a third module neither imports back.

  • Why does an exported function declaration survive the same cycle when an exported const does not?
    Function declarations are hoisted and *initialized* when the module environment is created, during linking — the function object exists before any body runs. A `const` binding is created at the same moment but left uninitialized until its declaration statement executes, so reading it from a partner that evaluates first hits the temporal dead zone and throws.
  • How does changing which file is the entry point change which module throws?
    Evaluation is a depth-first walk from the entry, so the entry decides which member of the cycle evaluates first — and the first one is the one that can see an unfinished partner. Entering at a.js makes b.js run first and b.js throws; entering at b.js flips it. The cycle is unchanged; only the victim moves.
  • What if the cyclic import is only read inside a function instead of at the top level?
    Then nothing throws at load time. Imported bindings are live references, so when the function is called — after both module bodies have finished — the binding is initialized and reads correctly. It is a real workaround, but the cycle remains in the graph and the next top-level read reintroduces the failure.
  • Why do cyclic class hierarchies fail so reliably in ESM?
    Because `class B extends A {}` evaluates the `A` expression at class-definition time, which is module-body position. If `A` comes from a module still mid-evaluation, its binding is uninitialized and the `extends` clause throws immediately. There is no deferring it the way a method body defers a read.

saying these in an interview costs you the question

  • ESM cycles are rejected at parse time
  • The import silently returns undefined like CommonJS
  • The error means the module was loaded twice
  • Moving the import statement lower in the file fixes it
  • Only bundled code hits this, not native ESM

context