JavaScript Modules (ESM vs CommonJS)

Medium100% Free~20 mins#esm#commonjs#modules#live-bindings#bundling#tree-shaking#dynamic-import
Key Learning Objectives
✓

Differentiate ECMAScript Modules (ESM) from CommonJS (CJS) in loading, parsing, and execution.

✓

Master ESM live bindings: how exported variables maintain real-time references to module state.

✓

Understand CommonJS value copying and why mutating an exported primitive does not update consumers.

✓

Distinguish static import statements from dynamic import() promises and tree-shaking implications.

The Interview Problem

What is printed to the console when the following code executes, and how does the live binding model of ES Modules contrast with CommonJS value exports?

1const esmExport = (() => {
2 let counter = 10;
3 return {
4 get counter() {
5 return counter;
6 },
7 increment() {
8 counter += 5;
9 },
10 };
11})();
12
13const cjsExport = (() => {
14 let counter = 10;
15 return {
16 counter,
17 increment() {
18 counter += 5;
19 },
20 };
21})();
22
23const beforeESM = esmExport.counter;
24const beforeCJS = cjsExport.counter;
25
26esmExport.increment();
27cjsExport.increment();
28
29const afterESM = esmExport.counter;
30const afterCJS = cjsExport.counter;
31
32console.log(beforeESM, beforeCJS, afterESM, afterCJS);
Predict Console Output
Interactive Challenge

Select the option that matches what standard ECMAScript prints to the console:

10 10 15 10

10 10 15 15

10 10 10 10

15 15 15 15

V8 Engine Execution Trace
Step 1 of 7 (Line 1)

esmExport simulation initializes module scope with counter = 10. In ESM, exports are live bindings to the module Environment Record (simulated here via a getter).

Call Stack (Top = Active)
Global Execution Context
esmExport init
Lexical Scope / Bindings
counter:10
Console Stream
> [empty]

Deep Technical Breakdown

ESM vs CommonJS: Core Architectural Differences

FeatureECMAScript Modules (ESM)CommonJS (CJS)
Export SemanticsLive bindings (real-time read-only pointers)Value copies (shallow snapshot on module.exports)
LoadingAsynchronous, 3-phase (Parse -> Instantiate -> Evaluate)Synchronous at runtime (require() blocks execution)
Static AnalysisTop-level import is statically analyzable -> Tree-shakingDynamic (require() can be called conditionally in functions)
Scope & Strict ModeModule scope, strictly 'use strict' by default, top-level this is undefinedFunction wrapper (exports, require, module, __filename, __dirname), top-level this is exports
Circular DependenciesSupported via live bindings and uninitialized TDZ slotsHandled via partial export snapshots (can return incomplete objects)

The 3-Phase ESM Lifecycle

  1. Construction (Parsing): Finds all import declarations and builds the Module Record dependency graph.
  2. Instantiation: Allocates memory space for all exported and imported bindings in module Environment Records without running code yet (linking).
  3. Evaluation: Executes top-level code in post-order traversal to populate the allocated memory boxes.
Common Traps & Mistakes

Attempting to reassign an imported binding in ESM (`import { count } from './mod'; count = 5`), which throws `TypeError: Assignment to constant variable` (imported bindings are immutable read-only views).

Using dynamic conditional imports with static `import` syntax: `if (condition) import foo from 'bar'` is a syntax error. Use `const foo = await import('bar')` instead.

Confusing Node.js `require()` synchronous behavior with browser ES module asynchronous script tags (`<script type="module">`), which defer execution until DOM parsing finishes.

FAANG Follow-Up Probes
Probe #1

How do circular dependencies behave in CommonJS versus ES Modules when a module accesses an imported variable before the dependency finishes executing?

Probe #2

Why can bundlers like Rollup and Webpack reliably tree-shake ES Modules but struggle to tree-shake CommonJS modules?

Probe #3

What is the 'dual package hazard' in npm libraries shipping both ESM and CommonJS builds, and how does package.json 'exports' field resolve it?