Module System (ESM vs CJS)
How JavaScript modules are loaded, linked, and cached β from static ES Modules to dynamic CommonJS require().
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.
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.
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').
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.
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
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.
ESM handles circular imports via live bindings (partially initialized). CommonJS may give undefined if a module isn't fully loaded yet at require() time.
Both systems cache modules after first load. require('./x') and import from './x' only execute the file once. Subsequent imports return the cached result.
import('./module.js') returns a Promise, enabling lazy loading. Works in both ESM and bundled CJS. Powers code splitting in Next.js/Webpack.
1// ====== ESM (ES Modules) ======2// Named exports:3export const API_URL = "https://api.example.com";4export function fetchUser(id) { /* ... */ }56// Default export:7export default class UserService { /* ... */ }89// Named imports:10import { API_URL, fetchUser } from "./api.js";1112// Default import:13import UserService from "./api.js";1415// Namespace import:16import * as api from "./api.js";1718// ====== CommonJS ======19// Exporting:20module.exports = { fetchUser, API_URL };21// or22exports.fetchUser = fetchUser;2324// Importing:25const { fetchUser, API_URL } = require("./api");26const api = require("./api");
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
1Bundle Size Optimization with Tree Shaking
You import one function from lodash: import { debounce } from 'lodash'. Your bundle is 70KB because the entire lodash library is included.
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.
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
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.
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.
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.