Module System in Node.js
Node.js has two module systems. CommonJS (CJS) is the original one:
require() and module.exports, loaded synchronously, at run time. ES
modules (ESM) are the JavaScript standard: import and export, analyzed
statically before the code runs, with live bindings and top-level await.
ESM can import CommonJS, and CommonJS can now require() an ES module too, as
long as it has no top-level await.
Applies to: Node.js 24 LTS.
Details
CommonJS vs ES Modules
| CommonJS | ES modules | |
|---|---|---|
| Syntax | require(), module.exports, exports | import, export, import() |
| Loading | Synchronous, when require() is reached | Graph is parsed and linked first, then executed |
| Imported values | Properties of the module.exports object; destructured values do not update | Live bindings: an import sees later changes of the export |
| Conditional / dynamic import | require() anywhere, with any expression | import only at the top level; import() returns a promise |
Top-level await | No | Yes |
| File extension in relative paths | Optional (./utils finds ./utils.js) | Required (./utils.js) |
| JSON | require('./data.json') | import data from './data.json' with { type: 'json' } |
| Current file and directory | __filename, __dirname | import.meta.filename, import.meta.dirname |
Top-level this | module.exports | undefined |
| Strict mode | Only with 'use strict' | Always |
How Node.js Decides the Module Type
.mjs→ ESM,.cjs→ CommonJS..js→ the"type"field of the nearestpackage.json:"module"or"commonjs".- No
"type"field: CommonJS, but if the file containsimport/exportsyntax (or top-levelawait), Node.js re-parses it as ESM. If apackage.jsonwithout"type"exists, it also prints a warning. Set"type"explicitly instead of relying on this.
Mixing the Two
- ESM imports CommonJS:
import pkg from './lib.cjs'givesmodule.exportsas the default export. Named imports work when Node.js can detect them statically (exports.name = …). - CommonJS requires ESM (
require(esm)): returns the module namespace object (the default export is in.default). If the module or its imports use top-levelawait, it throwsERR_REQUIRE_ASYNC_MODULE; useimport()instead. Unflagged in Node.js 22.12 and 20.19, no longer experimental since Node.js 24.15. - CommonJS loads ESM asynchronously:
await import('./esm.mjs')works in every version.
The CommonJS Module Wrapper
Before running a CommonJS file, Node.js wraps it in a function:
(function (exports, require, module, __filename, __dirname) {
// module code
});
- That is why each file has its own scope and why
require,module,exports,__filenameand__dirnameare available: they are parameters of the wrapper, not globals. In ESM they do not exist. - Real globals are
globalThis(alsoglobal),process,Buffer, timers,console,fetch,URLand others.
module.exports vs exports
exportsis a variable that initially points to the same object asmodule.exports.require()returnsmodule.exports.- Adding properties works through either:
exports.greet = …. - Reassigning
exports = …only changes the local variable; the module still exports the old object. To export a single value, assignmodule.exports = ….
Resolution
For require('x') and import 'x':
- Built-in module:
fs,node:fs. Built-ins win over files and packages with the same name even without the prefix;node:makes the intent explicit and bypassesrequire.cache. Some modules exist only with it (node:test,node:sqlite,node:sea). - Path (
./,../,/): a file; in CommonJS also with the.js,.json,.nodeextensions added, or a directory withpackage.jsonmainorindex.js(index.json,index.node). - Package name: looked up in
node_modulesof the current directory, then of every parent directory up to the root. - If the package has an
exportsfield inpackage.json, only the paths listed there can be imported, with different files forimportandrequire(conditional exports).importswith#defines private aliases inside the package.
Caching
- A module is executed once, on the first
requireorimport; later imports get the same object. That is why a module-level object works as a singleton. - CommonJS caches by resolved file path in
require.cache; ESM has its own module map that cannot be cleared. - Circular dependencies do not throw: in CommonJS the second module gets the
partially filled
module.exports; in ESM reading alet,constorclassexport before its module has run throws aReferenceError(function declarations are hoisted and work).
Example
Live bindings vs a copy:
counter.mjs
export let count = 0;
export function increment() {
count++;
}
counter.cjs
let count = 0;
module.exports = {
count,
increment() {
count++;
},
};
main.mjs
import { count, increment } from './counter.mjs';
import counterCjs from './counter.cjs';
increment();
console.log(count); // 1: the binding is live
counterCjs.increment();
console.log(counterCjs.count); // 0: the property was copied at export time
The exports trap:
greet.cjs
exports = function greet() {}; // require('./greet.cjs') returns {}
module.exports = function greet() {};
Follow-up Questions
- Why can't CommonJS just
requireany ES module?requireis synchronous, and an ES module with top-levelawaitmay finish evaluating only later. - How do you publish a package that works for both? The
exportsfield withimportandrequireconditions, or ESM only now thatrequire(esm)works in all supported Node.js versions. - How do you use
requireinside an ES module?createRequire(import.meta.url)fromnode:module. - Why does tree shaking need ESM? See ES Modules.
- How do you get
__dirnamein ESM?import.meta.dirname(Node.js 20.11+), orfileURLToPath(new URL('.', import.meta.url))in older versions.
Common Mistakes
- "
__dirname,requireandmoduleare globals." They are parameters of the CommonJS wrapper and do not exist in ESM. - "
module.exportsandexportsare the same." Only until one of them is reassigned;requirereturnsmodule.exports. - "ESM imports are copies, like
require." They are live bindings, read-only for the importer. - Omitting the extension in a relative ESM import:
import './utils'throwsERR_MODULE_NOT_FOUND.
See Also
- What Is Node.js
- ES Modules: modules in the browser, dynamic
import(), import maps - Package Managers