JavaScript Advanced #7 Module System in Depth

In Basics #7 Modules you saw how to use ES Modules. This post is about the inside — how they work, where they differ from CommonJS, and what happens with circular references. The final post of the JavaScript Advanced series.

Two module systems — ESM vs CommonJS #

Two module systems coexist in the JavaScript ecosystem.

ES Modules (ESM)CommonJS (CJS)
Syntaximport / exportrequire / module.exports
Loadingstatic (parse time)dynamic (runtime)
Executionasyncsync
Used inbrowsers, modern Nodeolder Node
StandardECMAScriptNode itself

ESM is the modern JavaScript standard, but the Node ecosystem ran on CommonJS for a long time. Remnants remain, and there are subtle traps when the two interact.

CommonJS — old Node modules #

CJS form
// math.js
function add(a, b) { return a + b; }
module.exports = { add };

// main.js
const { add } = require('./math');
add(2, 3);   // 5

require is a function call. At runtime, it synchronously reads and executes the module and returns module.exports.

conditional require
if (someCondition) {
  const lib = require('./optional');   // load conditionally
}

This is possible because it’s synchronous and dynamic — callable from anywhere, under any condition.

ESM — static + async #

ESM
// math.js
export function add(a, b) { return a + b; }

// main.js
import { add } from './math.js';
add(2, 3);   // 5

import isn’t a function — it’s a declaration. Resolved at parse time, not at runtime.

What “static” means #

ESM static — this isn't allowed
if (someCondition) {
  import { lib } from './optional.js';   // ✗ SyntaxError
}

import declarations live at module top-level only — not conditionally.

For conditional loading, dynamic import from Basics #7 is the answer.

dynamic import — conditional
if (someCondition) {
  const { lib } = await import('./optional.js');
}

It’s a function call, so usable anywhere. Returns a Promise.

ESM’s two phases — parse + execute #

What happens when an ESM module loads.

  1. Parse — read imports and build the dependency graph
  2. Link — connect each module’s exports to import slots
  3. Execute — run code top to bottom

Thanks to this separation, imports are hoisted.

import hoisting
console.log(add(2, 3));   // OK — import lifted up

import { add } from './math.js';

CommonJS’s require is a function call — the module isn’t loaded until require is actually called. ESM resolves all imports at parse time, so modules are always ready regardless of where in the file the import statement appears.

Live Binding — ESM exports are alive #

A big ESM difference. Imports aren’t value copies — they’re live references.

counter.js — ESM
export let count = 0;
export function increment() {
  count++;
}
main.js — ESM
import { count, increment } from './counter.js';

console.log(count);   // 0
increment();
console.log(count);   // 1  ← exported value updates

count isn’t a copy of 0 — it’s a live reference to the count variable inside counter.js. When counter updates, main sees it.

CommonJS doesn’t behave this way.

counter.js — CJS
let count = 0;
function increment() {
  count++;
}
module.exports = { count, increment };
main.js — CJS
const { count, increment } = require('./counter');

console.log(count);   // 0
increment();
console.log(count);   // 0  ← value at require time is fixed in

CJS’s module.exports object is destructured to capture the value at that moment — it’s copied. Subsequent changes to the original are not visible.

This difference is a common source of subtle bugs when mixing the two systems.

A module runs only once #

No matter how many places import the same module — it runs only once.

logger.js
console.log('logger module loaded');
export const logger = { log: (m) => console.log(m) };
both import
// a.js
import { logger } from './logger.js';

// b.js
import { logger } from './logger.js';

// main.js
import './a.js';
import './b.js';
// 'logger module loaded' prints only once

This is the basis for the singleton pattern in modules — a single object is built once and shared safely everywhere it’s imported.

Circular references — two modules importing each other #

What happens when two modules reference each other.

a.js
import { b } from './b.js';
export const a = 'A';
console.log('a.js loaded:', b);
b.js
import { a } from './a.js';
export const b = 'B';
console.log('b.js loaded:', a);

ESM is designed to handle this — but values may be empty depending on timing.

execution result
a.js loaded: B   ← b finished first, value present
b.js loaded: undefined  ← a's export not yet evaluated

ESM steps:

  1. main imports a.js — start parsing a.js
  2. a.js imports b.js — start parsing b.js
  3. b.js imports a.js — already loading, proceed (a’s binding is empty)
  4. Run b.js body — console.log('b.js loaded:', a) — a is still undefined
  5. b.js done, run a.js body — b is already ‘B’

Key point: with circular references, imported variables are live references that may be populated later, but at the time the module body executes they may still be empty.

The best way to avoid this trap is to not create circular references. If they appear, revisit your dependency structure. Splitting a module in two or extracting a shared dependency is usually the answer.

Circular references in CommonJS — riskier #

CJS works differently.

CJS circular
// a.js
const { b } = require('./b.js');
console.log('a.js:', b);
module.exports.a = 'A';

// b.js
const { a } = require('./a.js');
console.log('b.js:', a);
module.exports.b = 'B';

CJS’s require returns whatever module.exports holds at that moment, even if the module has only partially executed.

execution result
b.js: undefined   ← a.js only ran its first line; module.exports is empty
a.js: { b: 'B' }

ESM has a similar trap, but because CJS is synchronous, the mismatches surface at the visible-value level immediately. ESM’s live binding is subtly more forgiving.

Tree Shaking — an ESM-only advantage #

Bundlers (Vite, webpack, Rollup) can drop unused exports from the output. This is possible because ESM is statically analyzable.

ESM — tree shaking possible
// math.js
export function add() { /* ... */ }
export function subtract() { /* ... */ }
export function multiply() { /* ... */ }

// main.js
import { add } from './math.js';
add(2, 3);
// build output drops subtract, multiply

CJS’s require is dynamic, so a bundler can’t easily determine which exports are actually used. Libraries authored as ESM are therefore more bundle-size friendly.

Mixing ESM/CJS in Node #

Node 18+ supports both, but mixing them within a project has subtle implications.

package.json type field #

ESM project
{
  "type": "module"
}

With type: "module", .js is interpreted as ESM. Without it, CJS.

Distinguish by extension #

  • .mjs — always ESM
  • .cjs — always CJS
  • .js — depends on package.json’s type

Importing across systems #

Importing a CJS module from ESM is mostly possible (default import).

use CJS in ESM
import lodash from 'lodash';   // CJS package

Using ESM from CJS is harder — usually requires dynamic import.

use ESM in CJS
async function load() {
  const { default: chalk } = await import('chalk');
}

ESM-only libraries like chalk are increasingly common and often trip up older CJS codebases.

import.meta — module’s own info #

ESM modules have access to information about themselves.

import.meta
console.log(import.meta.url);   // module's own URL

// Vite-like environment
console.log(import.meta.env);   // env vars

CJS provides __filename and __dirname; in ESM, import.meta.url gives you the equivalent information.

building __filename in ESM
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

In Node 20+, import.meta.dirname and import.meta.filename are provided directly.

Wrap-up #

What we covered:

  • ESM is static + async; CJS is dynamic + sync
  • ESM imports are hoisted (resolved at parse)
  • ESM uses live binding — imports see export updates
  • CJS copies the value at the moment of require
  • A module runs only once (basis for singletons)
  • Circular references — ESM may also see empty values; avoid when possible
  • Tree shaking is possible thanks to ESM’s static structure
  • Mixing in Node — type: "module" / .mjs / .cjs
  • import.meta for module self info

Closing the Advanced series #

The 7 posts on JavaScript’s inner workings:

  1. Closures and scope — functions carrying their environment (#1)
  2. this binding — decided by call style (#2)
  3. Prototype chain — what classes really are (#3)
  4. Event loop — micro vs macro tasks (#4)
  5. Memory model — reachability and GC (#5)
  6. Symbol and Proxy — metaprogramming tools (#6)
  7. Module system — ESM vs CJS, circular references (this post)

With this in hand, you can answer almost any “why does JavaScript behave this way?” question. The next Practice series brings all these tools together to build a dynamic web page in vanilla JavaScript. Without any libraries, you’ll touch the DOM, events, fetch, and local storage to build one small app end to end.

X