JavaScript8 min read

ES Modules: Live Bindings, Evaluation Order, and Cycles

A closer look at what module loading guarantees—and what changes when the dependency graph contains a loop.

  • ES modules
  • imports
  • module graphs

ES modules are more than a syntax for splitting files. Static imports allow a host to discover a dependency graph before executing it, exported names behave as bindings rather than copied object properties, and each module is evaluated within a graph-wide ordering process.

For an acyclic graph, the result often feels simple: dependencies run before their importers. Circular dependencies expose the details that the simple rule leaves out.

Linking happens before evaluation

When a host loads an entry module, it parses static imports and recursively discovers dependencies. The graph is then linked: imported names are connected to the corresponding exported bindings and invalid imports can be rejected before application code starts running.

Only after this setup does evaluation execute top-level statements. A module is normally evaluated once for a particular module identity, even when several importers depend on it. Subsequent imports reuse the existing module instance.

This is different from taking a snapshot of an exports object. Consider a counter:

// counter.js
export let count = 0;

export function increment() {
  count += 1;
}
// dashboard.js
import { count, increment } from './counter.js';

console.log(count); // 0
increment();
console.log(count); // 1

The imported count observes reassignment performed by its declaring module. An importer cannot assign to count directly; the binding is read-only from the importing module’s point of view.

Default exports follow the same rule

Default exports are also live bindings. These two forms are therefore interchangeable with respect to later reassignment:

let currentTheme = 'light';
export default currentTheme;
let currentTheme = 'light';
export { currentTheme as default };

If currentTheme later becomes 'dark', importers of either module observe the new value. Choosing between the forms is mainly a matter of readability and whether the local binding needs to be named elsewhere in the exporting module.

Live bindings do not mean that every mutation is magical. Exporting an object exposes the same object identity. Mutating one of its properties is ordinary shared-object mutation, while assigning a new object to an exported let updates the binding itself.

Cycles are linked, then partially evaluated

Suppose a.js imports from b.js, while b.js imports from a.js. The loader does not recurse forever. It recognises that both files belong to the same strongly connected part of the graph, creates their module records, links the imports, and evaluates them in an order determined by graph traversal.

The important distinction is between a binding existing and its value being initialised.

// a.js
import { messageB } from './b.js';

export const messageA = 'A';
console.log(messageB);
// b.js
import { messageA } from './a.js';

export const messageB = `B sees ${messageA}`;

During a cycle, an imported binding that has not yet reached its declaration reads as undefined, much like a declared var before assignment. Once evaluation reaches the declaration, all importers see the initialised value. This makes cycles legal but potentially timing-sensitive.

Top-level function declarations are often easier to use across a cycle because their bindings are initialised during module instantiation. Values computed by top-level statements are more vulnerable to evaluation order. A practical way to reduce risk is to export functions that retrieve state later instead of reading the state during module startup.

Dynamic import changes discovery, not identity

import() returns a promise for a module namespace object. It lets an application defer loading and evaluation until a code path is reached:

const tools = await import('./heavy-tools.js');
tools.openInspector();

Calling import() repeatedly with the same resolved specifier returns the same promise object. The module is cached after its first successful evaluation, so callers share both the promise and the namespace object. This makes it safe to call a lazy loader from many places without coordinating an additional cache.

If evaluation fails, hosts remember that failure for the module record. Retrying the same specifier does not generally produce a clean new instance. Applications that need retry semantics should usually retry the underlying operation rather than relying on module re-evaluation.

Top-level await extends the graph

A module containing top-level await becomes asynchronous. Its importers wait for it to finish before their own evaluation continues, while unrelated branches of the graph may still make progress.

This is convenient for one-time initialisation, but it can make startup dependencies less visible. A low-level module that waits on network access can delay every importer above it. Cycles involving asynchronous modules are harder still: a graph can be linked correctly yet contain evaluation dependencies that never resolve.

The safest module graphs push side effects toward entry points, keep shared modules cheap to initialise, and avoid top-level reads across cycles. Static imports give tools and runtimes strong guarantees, but they do not turn a cyclic startup sequence into an intuitive one.