CommonJS vs ES Modules
Node.js has two competing systems for splitting code into reusable files: CommonJS (CJS), Node’s original module format built on require() and module.exports, and ES Modules (ESM), the standardized import/export syntax that JavaScript itself now defines. Every Node.js project has to pick — explicitly or by default — which system a given file uses, and the two are not simply interchangeable: they load differently, resolve paths differently, and expose different built-in variables. Understanding both, and how they interoperate, is essential for reading modern Node code and avoiding a whole category of confusing runtime errors.
Overview / How CommonJS and ES Modules Work
CommonJS is the module system Node.js shipped with from its earliest versions, years before JavaScript had any built-in module syntax. In CommonJS, every file is wrapped by Node in a function that supplies a few special local variables: require, module, exports, __dirname, and __filename. Calling require("./math") synchronously reads that file, executes it top to bottom, and returns whatever was assigned to module.exports. Because loading is synchronous and happens on first require, Node can cache the result — requiring the same file twice returns the same cached exports object, and circular require calls resolve to whatever the exports object contained at the point of the cycle (which can be a partially-built object).
ES Modules are the module system standardized in the ECMAScript specification itself — the same import/export syntax used in browsers. Node added native ESM support in v12+ and it is now fully stable. ESM is fundamentally different under the hood: imports and exports are static (analyzed before any code runs, so tools can determine the dependency graph without executing anything), bindings are live references to the exporting module’s variables (not a copied value, so if the exporter later reassigns an exported let, importers see the new value), and loading goes through an asynchronous module graph resolution step even for local files — that is why top-level await is legal in ESM but not CommonJS: the module loader already expects the graph to resolve asynchronously.
ESM files also do not have require, module, exports, __dirname, or __filename — those are CommonJS-only globals. Instead ESM exposes import.meta.url (and, since Node 20.11, import.meta.dirname and import.meta.filename directly).
How Node decides which system a file uses
Node picks CommonJS or ESM per file, based on (in order): the file extension — .cjs is always CommonJS, .mjs is always ESM — and otherwise the nearest package.json‘s "type" field. If "type": "module" is set, plain .js files in that package are treated as ESM; if "type" is "commonjs" or absent, .js files are CommonJS (the historical default).
Syntax
| Feature | CommonJS | ES Modules |
|---|---|---|
| Export | module.exports = value or exports.name = value |
export default value or export const name = value |
| Import | const x = require("./mod") |
import x from "./mod.js" |
| Relative path extension | optional (./math works) |
required (./math.js) |
| File extension for the format | .cjs, or .js with no "type": "module" |
.mjs, or .js with "type": "module" |
| Current directory info | __dirname, __filename |
import.meta.dirname, import.meta.filename (Node 20.11+) |
Top-level await |
not allowed | allowed |
| Loading another module type | await import("./esm-file.mjs") |
import cjsDefault from "./file.cjs" |
| Dynamic/conditional load | require() can be called anywhere, synchronously |
import() returns a Promise |
Examples
Example 1: A CommonJS module
A small math helper written the CommonJS way, exported with module.exports and pulled in with require. This still works today with no package.json changes, because CommonJS is Node’s default.
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
module.exports = { add, multiply };
const { add, multiply } = require("./math");
console.log("2 + 3 =", add(2, 3));
console.log("2 * 3 =", multiply(2, 3));
2 + 3 = 5
2 * 3 = 6
Note that require("./math") has no file extension — CommonJS resolution tries .js, .json, and .node automatically.
Example 2: The same module as an ES Module
To use ESM, the package needs "type": "module" in its package.json (or the files use the .mjs extension). Here the exports are named exports, and the importing file also uses a top-level await to read the package’s own metadata — something CommonJS cannot do at the top level.
{
"name": "esm-demo",
"version": "1.0.0",
"type": "module"
}
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
import { add, multiply } from "./math.js";
import { readFile } from "node:fs/promises";
console.log("2 + 3 =", add(2, 3));
console.log("2 * 3 =", multiply(2, 3));
const pkg = JSON.parse(
await readFile(new URL("./package.json", import.meta.url))
);
console.log("Running", pkg.name, "version", pkg.version);
2 + 3 = 5
2 * 3 = 6
Running esm-demo version 1.0.0
Notice the relative import must include the .js extension, and the file is located relative to the module using import.meta.url, since __dirname does not exist here.
Example 3: Loading an ES Module from CommonJS code
A CommonJS file cannot use the import statement, but it can still load an ESM package using the asynchronous import() function, which returns a Promise and works from both module systems. This is the standard way to consume an ESM-only npm package from an older CommonJS codebase.
async function loadEsmModule() {
const { default: chalk } = await import("chalk");
console.log(chalk.green("Loaded an ES module from CommonJS!"));
}
loadEsmModule();
npm install chalk
Going the other direction — requiring a CommonJS package from an ES Module — is simpler: import a CommonJS module directly and Node exposes module.exports as the default export.
How It Works Step by Step
When Node encounters require("./math") in a CommonJS file, it: (1) resolves the path and checks its internal module cache — if already loaded, it returns the cached exports object immediately; (2) otherwise reads the file synchronously from disk, blocking the event loop for that read; (3) wraps the file’s code in a function scope with module, exports, require, __dirname, __filename as arguments and executes it top to bottom; (4) caches and returns whatever module.exports ends up being. Everything happens synchronously and in program order — there is no equivalent of a “pending” module.
When Node encounters import statements in an ES Module, it instead: (1) parses the file to statically discover all its import specifiers without executing any code; (2) resolves and fetches each dependency, recursively repeating step 1 for each of them, building a full dependency graph before running anything; (3) links the graph, connecting each import to a live binding in the exporting module; (4) evaluates the modules in dependency order, leaves first. Because resolution is asynchronous by design, a module graph can contain files that need to fetch remote code or wait on I/O before evaluation — which is also why await is permitted at the top level of an ES Module: the loader is already built around asynchronous graph resolution.
Common Mistakes
Mistake 1: Using __dirname in an ES Module
__dirname and __filename are CommonJS-only. In an ESM file they are simply undefined, causing a ReferenceError.
import fs from "node:fs/promises";
import path from "node:path";
const configPath = path.join(__dirname, "config.json");
const data = await fs.readFile(configPath, "utf8");
console.log(data);
Fix it by deriving the directory from import.meta.url, or use import.meta.dirname directly on Node 20.11+:
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Node 20.11+: const __dirname = import.meta.dirname;
const configPath = path.join(__dirname, "config.json");
const data = await fs.readFile(configPath, "utf8");
console.log(data);
Mistake 2: Omitting the file extension in a relative ESM import
CommonJS’s require tolerates a missing extension; ESM’s resolver does not, and throws ERR_MODULE_NOT_FOUND at runtime.
import { add } from "./math";
console.log(add(2, 3));
import { add } from "./math.js";
console.log(add(2, 3));
Mistake 3: Top-level await in a CommonJS file
Top-level await is an ESM-only feature. In a CommonJS file (no "type": "module", plain .js or .cjs) it is a SyntaxError, because await is only valid inside an async function.
const fs = require("node:fs/promises");
const data = await fs.readFile("./config.json", "utf8");
console.log(data);
const fs = require("node:fs/promises");
async function main() {
const data = await fs.readFile("./config.json", "utf8");
console.log(data);
}
main();
Best Practices
- For new projects, prefer ES Modules — it is the standardized, future-facing format and the direction the whole JavaScript ecosystem is moving.
- Set
"type": "module"inpackage.jsononce per project rather than mixing.mjs/.cjsextensions file by file, unless you specifically need both formats side by side. - Always include the file extension in relative ESM imports (
./math.js, not./math). - Never mix
requireandimportin the same file — pick one system per file and stay consistent. - When you must consume an ESM-only package from CommonJS, use the asynchronous
import()function rather than trying torequireit. - Use
import.meta.dirname(Node 20.11+) instead of manually deriving a directory fromimport.meta.urlwhen your Node version supports it. - Check a third-party package’s
package.json"exports"/"type"fields before assuming which module format it ships — many packages are ESM-only or dual (“dual package”) today.
Practice Exercises
- Create a CommonJS module
greet.jsthat exports agreet(name)function viamodule.exports, and a second file thatrequires it and logs a greeting. - Convert that same module to ES Modules: add
"type": "module"to apackage.json, rename the export to useexport function greet(name), and update the importing file to useimportwith the correct file extension. - Write a small CommonJS script that uses the asynchronous
import()function to load an ESM-only npm package (for examplechalk) and prints colored text to the console.
Summary
- CommonJS (
require/module.exports) is Node’s original, synchronous module system; ES Modules (import/export) is the standardized JavaScript module system, resolved asynchronously. - Node picks the format per file using the
.cjs/.mjsextensions, or the nearestpackage.json‘s"type"field for plain.jsfiles. __dirname/__filenameexist only in CommonJS; ESM usesimport.meta.urlorimport.meta.dirnameinstead.- ESM relative imports require the file extension; CommonJS
requirecalls do not. - Top-level
awaitonly works in ES Modules; CommonJS must wrap awaited code in anasync function. - Cross-format interop is possible: ESM can
importa CommonJS module directly, while CommonJS must use the asynchronousimport()to load an ESM module.
