Live Binding in JavaScript ES Modules vs CommonJS

In JavaScript, ES Modules (ESM) utilize a mechanism called live binding, where imported identifiers maintain a direct, dynamic reference to the exported variables in memory. This differs fundamentally from CommonJS (CJS), which exports snapshot values evaluated at the time of execution. Understanding the mechanics of live bindings versus value copying is essential for managing shared state, handling circular dependencies, and debugging modern JavaScript applications.

Understanding Live Binding in ES Modules

In ECMAScript Modules (import/export), an imported variable is not a local copy; it is a read-only live reference (or pointer) to the memory location of the exported variable. If the exporting module mutates the variable internally, any module that imported that variable immediately reflects the updated value.

Consider the following ESM example:

// counter.mjs
export let count = 0;
export function increment() {
  count++;
}

// main.mjs
import { count, increment } from './counter.mjs';

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

Because of live binding, main.mjs sees the updated count value after calling increment(). Furthermore, imported bindings are immutable to the consumer; attempting to reassign count = 5 inside main.mjs throws a runtime TypeError.

How CommonJS Handles Exports

CommonJS (require/module.exports) uses value copying rather than live binding. When a module is imported via require(), Node.js evaluates the module and copies the exported values into the module.exports object.

Consider the equivalent behavior in CommonJS:

// counter.cjs
let count = 0;
function increment() {
  count++;
}
module.exports = { count, increment };

// main.cjs
const { count, increment } = require('./counter.cjs');

console.log(count); // Output: 0
increment();
console.log(count); // Output: 0

In this CJS example, count remains 0 inside main.cjs even after increment() is called. The require() call copied the initial primitive value of count onto the exported object. When increment() mutates the internal count variable inside counter.cjs, it does not update the copied property on the already-exported object.

Key Differences and Implications