Live Bindings and Module Cycles

An ES module import is a continuously updated reference, not a copied value — the mechanism that makes many circular imports resolvable.

Purpose

An ES module import binds to the exporting module’s actual variable, live — reading the import always reflects the exporting module’s current value, not a snapshot taken at import time.

Example

// counter.js
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 — the same binding, updated

Behavior notes

  • CommonJS require, by contrast, copies the exported value(s) at require time — mutating the original module’s internal state afterward is not reflected in an already-required copy for a primitive value (an object reference is still shared, but reassigning the exported binding itself is not visible).
  • A module cycle (A imports B, which imports A) can resolve correctly specifically because bindings are live — as long as nothing tries to use a binding’s value before the defining module finishes evaluating it.
  • Using a live binding at a cyclic module’s top level, before the cycle resolves, throws a “cannot access before initialization” error — a real constraint, not just a style preference.

Common mistakes

  • Assuming an ES module import behaves like a CommonJS require — copied once, not updated.
  • Structuring a circular dependency so a top-level statement in one module immediately uses a binding from the other, rather than deferring that use inside a function called later.

Runtime notes

Core ECMAScript module semantics, identical across every current browser and Node version.

Taught in these lessons

Related reference