HT
How Things Work

Module System (ESM vs CJS)

How JavaScript modules are loaded, linked, and cached β€” from static ES Modules to dynamic CommonJS require().

How It Works

JavaScript has two module systems: ES Modules (ESM) with import/export, and CommonJS (CJS) with require/module.exports. ESM is the standard, enabling static analysis, tree shaking, and top-level await. CJS is Node.js's legacy system, still widely used.

1
ESM: Static Analysis

ESM imports/exports are analyzed at parse time, BEFORE code execution. The engine builds a module graph, determines dependencies, and can tree-shake unused exports. This is why import/export must be at the top level β€” no conditional imports.

2
CommonJS: Runtime Execution

require() is a regular function call that runs at execution time. It synchronously reads the file, wraps it in a function, executes it, and caches the result. This means you CAN conditionally require modules: if (condition) require('./x').

3
Module Loading Phases (ESM)

1) Construction: Parse module, find imports, build module graph. 2) Instantiation: Allocate memory for exports, link imports to exports. 3) Evaluation: Execute module code top-to-bottom to fill in values. Each phase completes for the ENTIRE graph before the next begins.

4
Live Bindings vs Value Copies

ESM exports are live bindings β€” if the exporting module updates a variable, all importers see the change. CommonJS exports are value copies β€” require() gives you a snapshot. Re-requiring returns the cached copy, not a fresh read.

Key Concepts

🌳Tree Shaking

ESM enables bundlers to eliminate unused exports. import { x } from 'lib' tells the bundler exactly what's used. CommonJS can't do this because require() is dynamic.

πŸ”„Circular Dependencies

ESM handles circular imports via live bindings (partially initialized). CommonJS may give undefined if a module isn't fully loaded yet at require() time.

πŸ’ΎModule Cache

Both systems cache modules after first load. require('./x') and import from './x' only execute the file once. Subsequent imports return the cached result.

⚑Dynamic import()

import('./module.js') returns a Promise, enabling lazy loading. Works in both ESM and bundled CJS. Powers code splitting in Next.js/Webpack.

ESM vs CommonJS Syntax
tsx
1// ====== ESM (ES Modules) ======
2// Named exports:
3export const API_URL = "https://api.example.com";
4export function fetchUser(id) { /* ... */ }
5
6// Default export:
7export default class UserService { /* ... */ }
8
9// Named imports:
10import { API_URL, fetchUser } from "./api.js";
11
12// Default import:
13import UserService from "./api.js";
14
15// Namespace import:
16import * as api from "./api.js";
17
18// ====== CommonJS ======
19// Exporting:
20module.exports = { fetchUser, API_URL };
21// or
22exports.fetchUser = fetchUser;
23
24// Importing:
25const { fetchUser, API_URL } = require("./api");
26const api = require("./api");
πŸ’‘
Why This Matters

Understanding module systems is critical for bundle optimization (tree shaking depends on ESM), Node.js compatibility (CJS vs ESM migration), and debugging import errors (circular dependencies, live bindings vs copies).

Common Pitfalls

⚠require() and import are NOT interchangeable. require() is synchronous; import() is async. You can't use require() in ESM modules or top-level import in CJS.
⚠Circular dependencies behave differently: CJS gives a partial (potentially undefined) export at the point of the cycle. ESM uses live bindings but the value may not be initialized yet.
⚠'import * as' doesn't make a POJO β€” it creates a live Module Namespace object. You can't destructure it, JSON.stringify it, or use it like a regular object.
⚠Node.js file extension matters: .mjs is always ESM, .cjs is always CJS. .js depends on the nearest package.json's 'type' field.
Real-World Use Cases

1Bundle Size Optimization with Tree Shaking

Scenario

You import one function from lodash: import { debounce } from 'lodash'. Your bundle is 70KB because the entire lodash library is included.

Problem

lodash uses CommonJS internally. With CJS, the bundler can't determine which functions are used, so it includes everything. import { debounce } from 'lodash' still loads all of lodash.

Solution

Use lodash-es (ESM version): import { debounce } from 'lodash-es'. The bundler can now tree-shake, and only debounce (~1KB) is included. Alternatively: import debounce from 'lodash/debounce' (individual file).

πŸ’‘

Takeaway: ESM's static analysis is the foundation of tree shaking. When choosing libraries, prefer ESM-compatible packages. Check package.json for 'module' or 'exports' fields indicating ESM support.

2Migration from CommonJS to ESM

Scenario

Your Node.js project uses CommonJS (require/module.exports). You want to use a new library that only provides ESM. You get ERR_REQUIRE_ESM when trying to require() it.

Problem

CommonJS can't synchronously require() an ESM module (ESM is async by design). The reverse works β€” ESM can import() CJS modules dynamically. This creates a one-way compatibility wall.

Solution

Option 1: Convert your project to ESM (add 'type': 'module' to package.json). Option 2: Use dynamic import(): const { lib } = await import('esm-lib'). Option 3: Use a dual-package build that provides both CJS and ESM entry points.

πŸ’‘

Takeaway: The JS ecosystem is migrating from CJS to ESM. Understanding both systems and their interop limitations is essential for modern Node.js development and avoiding dependency compatibility issues.