Node.js Module System: CommonJS and ES Modules Explained
Key takeaways
Mixing the two module systems causes most Node import errors. The post compares their syntax and loading behavior, shows how caching makes a module a singleton, what a circular require returns, how the package.json type field changes how files load, and why __dirname is missing in ESM.
Introduction
What is a module?
A module is a reusable unit of code in its own file. Node.js supports two systems:
- CommonJS — default in Node (
require,module.exports) - ES modules — standard ECMAScript syntax (
import,export) Benefits:
- Reuse code across files
- Avoid polluting the global scope
- Keep features separated for maintenance
- Express dependencies explicitly
- Test units in isolation
The reason Node has two systems is historical. When Node appeared in 2009, JavaScript had no module syntax at all, so Node adopted the CommonJS convention: a synchronous require() function that reads a file from disk, runs it, and returns whatever it put on module.exports. ES modules were standardized in ES2015 with a very different design — imports are declarations the engine can analyze before running any code, and loading is asynchronous so it works over the network in browsers. Node supported ESM unflagged from version 12 onwards, but the enormous CommonJS ecosystem did not disappear, so every Node developer eventually has to understand where the two meet. Most “module” errors in practice are not about syntax; they are about Node deciding to treat a file as the other kind of module than you intended.
CommonJS modules
Basics
Export:
// math.js
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
const PI = 3.14159;
module.exports = {
add,
subtract,
PI
};
Import:
// app.js
const math = require('./math');
console.log(math.add(10, 5));
console.log(math.subtract(10, 5));
console.log(math.PI);
require('./math') resolves the path relative to the current file (not the directory you ran node from), runs math.js once, and returns its module.exports object. The extension can be omitted because CommonJS tries .js, .json, and .node in turn. Because require is an ordinary function call, it can appear anywhere — inside if blocks, inside functions, with computed paths — which is flexible but also means tools cannot know a file’s dependencies without running it. That is the main reason bundlers can tree-shake ESM far better than CommonJS.
exports vs module.exports
// OK: add properties to exports (same object as module.exports)
exports.add = (a, b) => a + b;
exports.subtract = (a, b) => a - b;
// OK: replace module.exports entirely
module.exports = {
add: (a, b) => a + b,
subtract: (a, b) => a - b
};
// WRONG: reassigning exports breaks the link to module.exports
exports = {
add: (a, b) => a + b
};
Conceptually, Node wraps your file and passes module and exports where exports starts as a reference to module.exports. Reassigning exports only changes the local variable.
Rules:
exports.foo = ...— OKmodule.exports = ...— OKexports = { ... }— does not change whatrequirereturns
The wrapper Node uses is literally a function: (function (exports, require, module, __filename, __dirname) { /* your file */ }). That explains several things at once — why top-level variables in a module are private, where __dirname comes from, and why exports = {...} fails silently (you reassigned a parameter). Mixing the two styles in one file is the other trap: if a file does exports.helper = ... and later module.exports = { main }, the helper export vanishes, because module.exports now points to a new object. Pick one style per file.
Export patterns
Multiple functions:
// utils.js
exports.formatDate = (date) => date.toISOString().split('T')[0];
exports.capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
Single class:
// user.js
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
greet() {
return `Hello, ${this.name}!`;
}
}
module.exports = User;
Singleton:
// database.js
class Database {
constructor() {
this.connection = null;
}
connect() {
if (!this.connection) {
this.connection = { connected: true };
}
return this.connection;
}
}
module.exports = new Database();
Factory:
// logger.js
function createLogger(prefix) {
return {
log: (message) => console.log(`[${prefix}] ${message}`),
error: (message) => console.error(`[${prefix}] ERROR: ${message}`)
};
}
module.exports = createLogger;
The singleton and factory patterns differ in who controls state. Exporting new Database() means every file that requires database.js shares one instance — convenient for a connection pool, awkward in tests, because the state survives between test cases and cannot easily be replaced with a fake. Exporting a factory (or the class itself) lets the caller decide how many instances exist and with which configuration. When in doubt, export the factory and create the shared instance in one place at startup.
ES modules
Setup
package.json:
{
"type": "module"
}
Or use the .mjs extension.
The "type" field applies to every .js file in that package (up to the next package.json in a subfolder). With "type": "module", .js means ESM and you use .cjs for any file that must stay CommonJS — config files for older tools are the usual case. Without it (or with "type": "commonjs"), .js means CommonJS and .mjs opts into ESM. Recent Node versions will also detect ESM syntax in an ambiguous .js file and reload it as a module, printing a warning, but relying on that costs a double parse and hides the misconfiguration; set "type" explicitly.
Named exports
// math.mjs
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export const PI = 3.14159;
function multiply(a, b) { return a * b; }
function divide(a, b) { return a / b; }
export { multiply, divide }; // export list form for already-declared bindings
// app.mjs
import { add, subtract, PI } from './math.mjs';
import { add as plus } from './math.mjs';
import * as math from './math.mjs';
Note the file extension in './math.mjs'. In ESM, Node does not try extensions or index.js for relative imports; the specifier must name the file exactly. import { add } from './math' fails with Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/math' imported from /app/app.mjs, often followed by Did you mean to import "./math.mjs"?. This is the single most common error when converting a CommonJS project, and TypeScript users meet a confusing variant: with "module": "NodeNext" you write import './math.js' in a .ts file, because the specifier must match the compiled output.
Named imports are also not copies. An ESM import is a live, read-only binding to the exporting module’s variable: if the module later changes an exported let, importers see the new value, and assigning to an imported name throws TypeError: Assignment to constant variable. This is different from CommonJS, where const { count } = require('./counter') copies the value at that moment.
Default export
// calculator.mjs
export default class Calculator {
add(a, b) { return a + b; }
subtract(a, b) { return a - b; }
}
export const VERSION = '1.0.0';
import Calculator, { VERSION } from './calculator.mjs';
Dynamic import()
async function loadModule() {
if (condition) {
const mod = await import('./heavy-module.mjs');
mod.doSomething();
}
}
import() is the escape hatch from static imports: it takes a runtime string, returns a promise for the module namespace, and works in both ESM and CommonJS files. It is the right tool for loading optional or heavy dependencies only when needed, and for loading plugins whose paths come from configuration. A default export appears as the default property of the returned namespace ((await import('./calculator.mjs')).default), which trips people up when switching from static imports. ESM also allows top-level await, so a module can await its setup before exporting — but every importer then waits too, and a module that awaits something that never resolves stalls the whole import graph.
CommonJS vs ES modules
| Feature | CommonJS | ES modules |
|---|---|---|
| Syntax | require, module.exports | import, export |
| Loading | Synchronous require at runtime | Parsed statically; async load |
| Extension | .js (default) | .mjs or "type": "module" |
| Default export | module.exports = ... | export default |
| Tree shaking | Limited | Yes |
| Browser | No (without bundler) | Native in modern browsers |
Use CommonJS for legacy code, many older packages, or quick scripts.
Use ES modules for new apps, shared browser/Node code, and bundler-friendly tree shaking.
Interop
From CommonJS, load ESM:
async function loadESM() {
const mod = await import('./es-module.mjs');
mod.default();
}
From ESM, load CommonJS:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const cjs = require('./commonjs-module.js');
Both directions have simpler options than they used to. From ESM, you can usually just import cjs from './commonjs-module.cjs': the default import is the CommonJS module.exports, and Node also exposes named imports when it can detect them statically (exports.foo = ... patterns). createRequire remains useful for require.resolve or for loading JSON in older versions. From CommonJS, recent Node releases (22.12+, and 20.19+ on the 20.x line) can require() an ES module synchronously, as long as that module does not use top-level await; if it does, you get ERR_REQUIRE_ASYNC_MODULE and must fall back to import(). On older versions, require() of any ES module throws ERR_REQUIRE_ESM, which is the error that pushed many library authors to publish dual packages.
When I migrated projects between the two systems, the problems were rarely in my own code — they came from dependencies that became ESM-only in a major version. Pinning the last CommonJS version buys time, but the lasting fix is moving the project to ESM or using import() at the boundary.
Built-in modules (overview)
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const url = require('url');
const querystring = require('querystring');
const os = require('os');
const crypto = require('crypto');
const EventEmitter = require('events');
const stream = require('stream');
const child_process = require('child_process');
Prefer the node: prefix for built-ins — require('node:fs'), import fs from 'node:fs'. It makes clear that the module is part of Node rather than an npm package, cannot be shadowed by a package with the same name, and is required for newer built-ins such as node:test and node:sqlite. Two modules in this list are legacy: url’s old url.parse() API is superseded by the WHATWG URL class, and querystring by URLSearchParams, both available globally.
fs (promises)
const fs = require('fs').promises;
const path = require('path');
async function fileOperations() {
try {
const data = await fs.readFile('input.txt', 'utf8');
await fs.writeFile('output.txt', 'Hello, Node.js!', 'utf8');
await fs.appendFile('output.txt', '\nMore', 'utf8');
await fs.copyFile('output.txt', 'backup.txt');
await fs.rename('backup.txt', 'backup-new.txt');
await fs.unlink('backup-new.txt');
const stats = await fs.stat('output.txt');
console.log(stats.size, stats.mtime);
await fs.mkdir('new-folder', { recursive: true });
const files = await fs.readdir('.');
await fs.rmdir('new-folder');
} catch (err) {
console.error('Error:', err.message);
}
}
fileOperations();
path
const path = require('path');
const filePath = path.join(__dirname, 'data', 'users.json');
const absolutePath = path.resolve('data', 'users.json');
console.log(path.basename('/foo/bar/file.txt')); // file.txt
console.log(path.basename('/foo/bar/file.txt', '.txt')); // file
console.log(path.dirname('/foo/bar/file.txt')); // /foo/bar
console.log(path.extname('file.txt')); // .txt
console.log(path.parse('/foo/bar/file.txt'));
console.log(path.normalize('/foo/bar/../baz'));
console.log(path.relative('/foo/bar', '/foo/baz/file.txt'));
The difference between the first two lines matters more than it looks. path.join(__dirname, ...) builds a path relative to the file’s own location, so it works no matter where the process was started. path.resolve('data', ...) resolves against process.cwd(), the directory node was run from — so a script that works with node app.js breaks with node src/app.js or when a process manager starts it from /. For files that ship with your code, anchor paths to __dirname (or import.meta.dirname in ESM). Relative paths passed to fs functions are also resolved against cwd, which is why fs.readFile('input.txt') in the previous example depends on where you run it.
os
const os = require('os');
console.log('platform:', os.platform());
console.log('CPUs:', os.cpus().length);
console.log('total mem GB:', (os.totalmem() / 1024 / 1024 / 1024).toFixed(2));
console.log('free mem GB:', (os.freemem() / 1024 / 1024 / 1024).toFixed(2));
console.log('homedir:', os.homedir());
console.log('tmpdir:', os.tmpdir());
console.log('networkInterfaces:', os.networkInterfaces());
crypto
const crypto = require('crypto');
// Fine for checksums; NOT for passwords (see below)
function sha256(input) {
return crypto.createHash('sha256').update(input).digest('hex');
}
const randomString = crypto.randomBytes(16).toString('hex');
const { randomUUID } = require('crypto');
console.log(randomUUID());
A fast unsalted hash like SHA-256 is the wrong tool for storing passwords: identical passwords produce identical hashes, and a GPU can try billions of guesses per second against a leaked table. Use a deliberately slow, salted key-derivation function — crypto.scrypt is built in, and bcrypt or argon2 are common packages — and compare hashes with crypto.timingSafeEqual. randomBytes and randomUUID draw from the operating system’s cryptographically secure generator, so unlike Math.random() they are suitable for tokens and ids.
Module caching
// counter.js
let count = 0;
exports.increment = () => {
count++;
console.log('Count:', count);
};
exports.getCount = () => count;
const counter1 = require('./counter');
const counter2 = require('./counter');
counter1.increment();
counter2.increment();
console.log(counter1 === counter2); // true
Clear cache (testing):
delete require.cache[require.resolve('./counter')];
const counter3 = require('./counter');
The cache is keyed by the resolved absolute filename, not by the string you passed. That is why require('./counter') and require('../lib/counter') from different folders share state, and also why “singletons” sometimes aren’t: if two packages each depend on a different copy of the same library (two versions in nested node_modules), each copy has its own path and its own state. The classic symptom is a library complaining that an object “is not an instance of” its own class, or React’s “Invalid hook call” warning caused by two React copies. npm ls <package> shows duplicates.
Deleting from require.cache forces a fresh evaluation of that one file, but modules it required stay cached, and other files still hold references to the old exports — so it is a testing hack, not a hot-reload mechanism. ES modules have no public cache to clear at all; test runners isolate them by running each test file in a separate module context.
Circular dependencies
Problem: a.js requires b.js, b.js requires a.js — partially initialized exports may be undefined.
Concretely: Node starts running a.js, which hits require('./b'). Node starts b.js, which hits require('./a'). Since a.js is already in the cache (loading), Node returns its module.exports as it is right now — typically an empty object, because a.js has not reached its export statements. b.js finishes with that incomplete object, and any top-level code in b that uses a.something gets undefined, often surfacing as TypeError: a.something is not a function. Worse, if a.js later does module.exports = {...}, b keeps holding the old empty object forever. Recent Node versions print a warning when you access a missing property of a module in a circular dependency, which helps track it down. In ESM the same cycle behaves differently: bindings are live, so functions called later work, but touching an exported const or class before it is initialized throws ReferenceError: Cannot access 'x' before initialization.
Fix 1 — shared module:
// shared.js
exports.nameA = 'Module A';
exports.nameB = 'Module B';
Fix 2 — lazy require inside a function:
exports.greet = () => {
const a = require('./a');
console.log(`B sees: ${a.name}`);
};
Fix 3 — dependency injection (pass dependencies after both load).
Module resolution
require('./math'); // relative
require('../utils/math');
require('express'); // node_modules
require('fs'); // built-in
Resolution order for ./math in CommonJS: math as an exact file, then math.js, math.json, math.node; if math is a directory, the main field of math/package.json, then math/index.js.
require.resolve('express') shows the resolved path.
Bare names like 'express' are looked up in node_modules in the current directory, then each parent directory up to the filesystem root. For packages that define an "exports" field in their package.json, that map takes precedence over main and restricts what can be imported: requiring an internal path the package does not export fails with ERR_PACKAGE_PATH_NOT_EXPORTED, even though the file exists on disk. The same field can provide different entry points for import and require (conditional exports), which is how dual CommonJS/ESM packages work. ESM uses the same node_modules lookup and exports map, but none of the extension or index.js guessing.
Practical examples
Common patterns include config (dotenv plus a module that validates and exports settings once), logger (a shared instance created at startup), a database wrapper exporting a pool, and an API client wrapping fetch with a base URL and error handling. In each case the module system does the wiring: the first require or import creates the object and every later one receives the same instance, so keep module top-level code limited to creating things, not doing network I/O or reading files that may not exist in tests.
package.json advanced
{
"name": "my-package",
"version": "1.0.0",
"main": "index.js",
"dependencies": { "express": "^4.18.2" },
"devDependencies": { "nodemon": "^3.0.1" }
}
Semantic versioning: ^4.18.2 allows minor/patch updates below 5.0.0; ~4.18.2 allows patch only; pin exact versions when you need reproducible builds. In practice the lockfile (package-lock.json) is what makes installs reproducible — commit it and use npm ci in CI, which installs exactly what the lockfile says and fails if it disagrees with package.json. Note that for 0.x versions the caret is stricter: ^0.4.2 allows only 0.4.x, because semver treats every 0.x minor as potentially breaking.
Lifecycle npm scripts: prebuild, build, postbuild run in order when you npm run build.
Module patterns
Singleton with guard:
class Database {
constructor() {
if (Database.instance) return Database.instance;
this.connection = null;
Database.instance = this;
}
}
module.exports = new Database();
IIFE module with private state:
const counter = (() => {
let count = 0;
return {
increment() { return ++count; },
getCount() { return count; }
};
})();
module.exports = counter;
The guard in the first pattern is redundant as long as everyone goes through require — the module cache already guarantees one instance per resolved path. It only helps if some code calls new Database() directly. The IIFE pattern predates modules and is mostly unnecessary now, since a module’s top-level variables are already private; it is shown because you will still see it in older code.
Common problems
Cannot find module
Check path typos, install the package, or add "type": "module" / .mjs for ESM. The error text tells you which resolver failed: Error: Cannot find module './math' with a “Require stack” comes from CommonJS, while ERR_MODULE_NOT_FOUND comes from ESM — and in ESM, a missing file extension is the most likely cause. Paths are also case-sensitive on Linux but not on default macOS and Windows file systems, so require('./Math') can work on a laptop and fail in a Linux container or CI.
import outside a module
Add "type": "module" to package.json or rename to .mjs. The full message is SyntaxError: Cannot use import statement outside a module. The mirror-image error, ReferenceError: require is not defined in ES module scope, you can use import instead, means the file is treated as ESM (often because of "type": "module" you didn’t notice in a parent folder) but uses require.
No __dirname in ESM
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
ES modules don’t get the CommonJS wrapper function, so __dirname, __filename, require, module, and exports simply don’t exist. The snippet above reconstructs the first two from import.meta.url, which is a file:// URL. On Node 20.11+ and 21.2+ you can use the built-ins import.meta.dirname and import.meta.filename instead. For reading files next to the module, new URL('./data.json', import.meta.url) can be passed straight to fs functions without converting to a path.
Project structure tip
src/
├── config/
├── models/
├── controllers/
├── routes/
├── middlewares/
├── utils/
└── index.js
Use models/index.js to re-export models for cleaner imports.
Next in the series
The next part, Async: callbacks, Promises, async/await, picks up where module loading ends. For the exact resolution and caching rules discussed above, the reference pages are Node.js Modules and ECMAScript Modules in Node.
Related Articles
- Getting Started with Node.js: Install, Setup, and Hello
- Node.js Async Programming: Callbacks, Promises, and
- Express.js Guide
- Node.js File System (
fs) - JavaScript Modules | ES6 Modules, CommonJS Explained