Backend Node.js Course · Level 1
Node.js Module System, CommonJS vs ESM
On this page
1. CommonJS Caching Mechanism (require)
- When a file is
require()d for the first time, Node executes that file and stores the result (the object returned frommodule.exports) in an internal cache, using the absolute file path as the key. - Subsequent
require()calls to the same file do not re-run the code; they simply return the exact same cached object from the initial call. - Because every consumer requiring the same file receives the exact same object reference (not an isolated copy), the internal state of that object (variables enclosed in closures) is shared across the entire application.
- Practical application: The standard pattern of "initializing a database connection once inside
db.jsand exporting it for app-wide use" works correctly thanks to this caching mechanism. If Node re-executed the file on every require, each consumer would create an independent DB connection, quickly exhausting the connection pool limit.
2. Live Binding in ESM (import/export)
Code Example:
js
// counter.mjs
export let count = 0;
export function increment() { count++; }
// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1, reflects the updated value
- ESM
importdoes not create a value copy at import time; it creates a live binding: a direct reference to the exact memory location where the variable exists in the source module. - Therefore, even though
countis a primitive number type, the importing side always observes the latest value, rather than a stale snapshot taken at import time. - CommonJS behaves inversely: if you directly export a primitive variable, the consumer receives only the value at the moment of
require()and will not see subsequent updates (since primitives are passed by value, not by reference). - For objects and functions, both module systems reflect mutations (as they are passed by reference); the live binding distinction is primarily evident with primitives.
- Additionally, ESM enforces imported bindings in read-only mode: you cannot reassign an imported variable or function (
Assignment to constant variable). CommonJS allows free mutation or reassignment on the consumer side (without affecting the source file).
3. General Comparison Table
CommonJS (require) | ESM (import) | |
|---|---|---|
| Mechanism | Copies the exported value at require time, caches by absolute path | Live binding, direct reference to the original variable |
| Objects / Functions | Reflects mutations | Reflects mutations |
| Directly Exported Primitives | Does not reflect mutations, captures value at require time | Always reflects the latest value |
| Reassignment on Import Side | Allowed | Not allowed, read-only |
| Loading Timing | Synchronous, executes immediately upon require(), supports dynamic/conditional loading | Static, fixed declarations at top level, supports dynamic import() separately |
| Static Analysis & Tree-Shaking | Difficult, because require() can be called dynamically or conditionally | Easy, bundlers can analyze dependencies without executing code |
4. Declaring a File/Project as ESM or CommonJS
- By default, Node parses
.jsfiles using CommonJS syntax. Writing animportstatement in such a file throws aSyntaxError: Cannot use import statement outside a module. - 2 ways to tell Node that a file is an ES module:
- Use the
.mjsfile extension (applies per file). The.cjsextension is used to force a file to run as CommonJS when the project defaults to ESM. - Add
"type": "module"topackage.json(applies to the entire project; all.jsfiles are treated as ESM by default, except.cjsfiles).
- If a project specifies
"type": "module"but relies on a legacy CommonJS-only library, you cannot userequire()directly inside an ESM file. Workaround: use a dynamic importconst lib = await import('legacy-library'), where Node automatically wrapsmodule.exportsas the default export.
5. Common Misconception: import Syntax in TypeScript Does Not Guarantee ESM at Runtime
- The TypeScript compiler compiles the
import/exportsyntax you write into the actual module system executed at runtime, based on the"module"setting intsconfig.json. - If
tsconfig.jsonsets"module": "commonjs", writingimport express from 'express'in a.tsfile will be compiled intoconst express = require('express')in the output.js. The runtime executes genuine CommonJS, not ESM. - Applied to previous projects: even if code is written using
import/exportsyntax, iftsconfig.jsonhasmodule: commonjsandpackage.jsonlacks"type": "module", the actual runtime runs CommonJS. All real-world behaviors (module caching, absence of true live bindings, dynamic/conditionalrequire()execution) follow CommonJS semantics.
