Free lesson · JavaScript Basic to Advance
CommonJS and the require/module.exports World
The Other Module System
Before ES Modules existed, Node.js had its own module system: CommonJS. It uses require to import and module.exports to export, and it's still everywhere in the Node ecosystem. Even modern code often mixes both systems for compatibility.
// math.cjs — CommonJS export
function add(a, b) { return a + b; }
function multiply(a, b) { return a * b; }
module.exports = { add, multiply };
// app.cjs — CommonJS import
const { add, multiply } = require("./math.cjs");
console.log(add(2, 3));
This is the syntax you'll see in:
- Older Node.js projects (pre-2020 most code)
- npm package internals (many libraries still publish CommonJS)
- Server-side code in mature applications
- Node.js built-in modules (
fs,path,http) when accessed in CommonJS files
Modern code is moving to ES Modules, but CommonJS isn't disappearing. You'll work with both. This Topic covers CommonJS syntax, the semantic differences from ES Modules, and the interop patterns when you need to mix them.
CommonJS Syntax
The mechanical patterns:
Export by assigning to module.exports:
// Single value export — common for one-thing-per-file
module.exports = function logger(msg) { console.log(msg); };
// Object export — multiple things
module.exports = {
add: function(a, b) { return a + b; },
multiply: function(a, b) { return a * b; }
};
// Property-by-property — same as above, written differently
module.exports.add = function(a, b) { return a + b; };
module.exports.multiply = function(a, b) { return a * b; };
// Shorthand: 'exports' is an alias for 'module.exports' initially
exports.add = function(a, b) { return a + b; };
// ⚠️ Don't reassign 'exports' directly — it breaks the alias
The last point is a common source of confusion. exports is a local variable initialized to point to module.exports. You can use exports.something = ... and it modifies module.exports. But exports = something only rebinds the local variable — it doesn't change what gets exported.
Import with require:
// Get the whole exports
const math = require("./math.cjs");
math.add(2, 3);
// Destructure named exports
const { add, multiply } = require("./math.cjs");
// Get a single-value export
const logger = require("./logger.cjs");
logger("hello");
require is a regular function. It takes a path, loads the file, runs it, and returns whatever was assigned to module.exports. The first call caches the module — subsequent calls to require with the same path return the cached value without re-running the file.
Semantic Differences From ES Modules
CommonJS and ES Modules look similar but have important differences in semantics.
1. CommonJS is synchronous; ES Modules can be async. require loads the module immediately and blocks until done. ES Modules can be loaded lazily and asynchronously (especially in browsers).
2. CommonJS exports are values; ES Module exports are bindings. This is the most-confusing difference.
// counter.cjs — CommonJS
let count = 0;
function increment() { count++; }
module.exports = { count, increment };
// app.cjs
const { count, increment } = require("./counter.cjs");
console.log(count); // 0
increment();
console.log(count); // STILL 0 — CommonJS exported a snapshot
When CommonJS exports, you're copying the current value into the exports object. If the source file later changes its internal count, the import doesn't see the change. ES Modules expose live bindings — Topic 11.2 covered this. CommonJS exposes snapshots.
3. CommonJS is dynamic; ES Modules are static. require is a function call — you can compute the path at runtime. ES Module imports must be top-level with static paths.
// CommonJS — works
const moduleName = condition ? "moduleA" : "moduleB";
const mod = require(`./${moduleName}.cjs`);
// ES Modules — requires the dynamic import() function
const mod = await import(`./${moduleName}.js`);
This dynamic nature makes CommonJS harder for tools to analyze. Bundlers and type checkers can't always know what a require call will resolve to. ES Module imports are explicit and analyzable.
4. CommonJS has no this issue at module level. this at the top of a CommonJS file is module.exports. ES Modules' top-level this is undefined.
These differences are why some Node.js packages publish multiple builds — one CommonJS and one ES Module — to support both consumer styles.
Interactive animation: The counter, replayed in CommonJS - the exported count is a copied snapshot, so it still reads 0 after incrementing.
The Wrapping Function
A subtle detail of CommonJS: every CommonJS file is wrapped in a function before execution. Conceptually:
// Conceptual wrapper — Node does this for every CommonJS file
(function(exports, require, module, __filename, __dirname) {
// your file contents
})(/* the actual values */);
This is why module, exports, require, __filename, and __dirname are available in every CommonJS file but aren't actual globals. They're injected as function parameters.
This wrapping is also why var declarations at the top of a CommonJS file aren't truly global — they're scoped to the wrapper function. In ES Modules, the same isolation exists but it's enforced by the module system rather than by an implicit wrapper.
Interop: Mixing the Two
In modern Node.js, you can use both module systems in the same project. The rules for mixing are:
ES Module files can import CommonJS modules. Node provides interop that makes CommonJS imports look like ES Modules:
// my-esm.mjs (ES Module)
import math from "./math.cjs"; // imports the whole module.exports as default
import { add } from "./math.cjs"; // works if math.cjs exported an object
The default import gets the whole module.exports value. Named imports work if module.exports is a plain object (which is most common).
CommonJS files can NOT use require to load ES Modules directly. Until recently, you'd need to use the dynamic import() function inside CommonJS to load ES Modules:
// my-cjs.cjs (CommonJS)
async function loadESM() {
const mod = await import("./my-esm.mjs");
return mod.someThing;
}
Recent Node versions have improved this with synchronous require() for some ES Module cases, but the dynamic import() approach is the most portable.
The file extension rules tell Node which system to use:
.mjs→ ES Module.cjs→ CommonJS.js→ depends on the nearestpackage.json's"type"field
If "type": "module" is set in package.json, .js files are ES Modules. Otherwise (or with "type": "commonjs"), they're CommonJS. This is one of the most common confusions in modern Node.js projects.
Interactive animation: Who is what, and who may load whom - .mjs, .cjs and the type field, then the rules for crossing between systems.
Which One to Use
For new code in 2026, use ES Modules unless you have a specific reason not to:
- ES Modules are the language standard
- They're better for static analysis and tooling
- They work in both Node and browsers without modification
- The whole ecosystem is moving in this direction
For existing CommonJS code, you don't usually need to migrate. CommonJS will be supported in Node.js for many years. Mixed projects work fine. The cost of migrating a working CommonJS codebase rarely justifies the effort.
For libraries you publish, the modern convention is to ship both — CommonJS for older consumers, ES Modules for modern ones. Most package builders (tsup, microbundle, modern Rollup configurations) handle this automatically.
Common Pitfalls
Interactive animation: The alias you can attach to but never replace - attaching to exports works, reassigning it only rebinds a local variable.
Warning: The CommonJS traps.
Reassigning
exportsinstead ofmodule.exports.exports = somethingNewdoesn't work — it just rebinds the local variable. Usemodule.exports = somethingNewfor whole-exports replacement.Expecting live bindings from CommonJS. CommonJS exports are snapshots. If you need live updates, export a getter function or expose state through a method.
Mixing extensions and
"type"settings inconsistently. A.jsfile with"type": "module"is an ES Module. The same file in a different package or with no"type"is CommonJS. This catches everyone at some point.Importing a CommonJS default in ESM. The whole
module.exportsbecomes the default. If you wrotemodule.exports = { a, b }and tryimport { a } from "./mod"in ESM, it might work (named imports of object properties). But if you wrotemodule.exports = function() {}, only the default import works.
Why This Matters in Interviews
Interview: "What's the difference between
requireandimport?" is a standard mid-level interview question. The expected answer covers: CommonJS vs ES Modules, synchronous vs static, snapshot vs binding, dynamic vs analyzable.Senior questions probe deeper: "Why is
requiresynchronous? Why can't browsers use it?" The answer: synchronous module loading would block the network requests browsers depend on. ES Modules can be loaded async, which fits the browser model.Topic 11.7 — Interview Lens: Module System Questions — covers the deeper treatment.
Try It
Exercise: Take an ES Module file and write its CommonJS equivalent. Then run both with Node and verify they produce the same output:
// esm.mjs export const PI = 3.14159; export function area(r) { return PI * r * r; }Write the equivalent
.cjsfile. Note where the syntax differs and where the semantics differ.Then try the harder exercise: in your CommonJS file, export an object containing both a
valueand anincrementfunction. Try mutatingvaluefrom outside via the increment function. Does the importedvaluechange? Why or why not? The answer demonstrates the snapshot vs binding distinction concretely.