Node.js supports two module systems, and for a period of several years, this has been one of the most genuinely confusing aspects of working in the JavaScript ecosystem. Packages break unexpectedly. Import syntax that works in one context fails in another. Error messages point at module types without explaining what to do about them.
The confusion is not a sign that something is wrong with your code. It is the result of a real historical tension — a language standard arriving years after an ecosystem had already settled on a different approach. Understanding that history, and understanding precisely how the two systems differ, is the clearest path through it.
How We Got Here
When Node.js launched in 2009, JavaScript had no official module system. The language specification had no import or export keywords. Node.js needed a way for files to share code, so it implemented its own: CommonJS, using require() and module.exports.
The entire Node.js ecosystem — every package ever published to npm in those early years — was built on CommonJS. By the time the JavaScript language specification standardised its own module system in ES2015 (ES6), the Node.js ecosystem had years of momentum behind CommonJS.
Node.js did not add stable ES Module support until version 12 in 2019, a full four years after the language standard was published. Even then, adoption was gradual. Many packages continued shipping CommonJS because changing module systems in a widely-used package is a breaking change for consumers who have not yet moved to ESM.
The result is an ecosystem in transition — new code increasingly uses ES Modules, existing code is largely CommonJS, and both systems must coexist in the same runtime.
The Core Difference: How Modules Are Loaded
The most fundamental difference between the two systems is not syntax — it is how and when modules are loaded.
CommonJS loads modules synchronously. When Node.js encounters a require() call, it stops, loads the file, executes it, and returns the exports before moving on to the next line. This is simple and predictable, but it means require() can only appear where synchronous code is valid — it cannot wait for a Promise.
ES Modules load asynchronously. The JavaScript engine first parses the entire module file, identifies all import statements, fetches and parses those modules, resolves the full dependency graph, and only then begins executing code. Nothing executes until the entire graph is resolved. This is slower to start, but it enables things CommonJS cannot: top-level await, static analysis, and tree-shaking.
This distinction — synchronous vs asynchronous loading — is the root cause of most interoperability friction between the two systems.
Syntax
The surface difference is the syntax used to export and import values.
Exporting
CommonJS:
// Single export — replace module.exports entirely
module.exports = function greet(name) {
return `Hello, ${name}`;
};
// Multiple exports — add properties to module.exports
module.exports = {
greet,
farewell,
};
// Shorthand — add properties to exports directly
exports.greet = greet;
exports.farewell = farewell;
ES Modules:
// Named exports
export function greet(name) {
return `Hello, ${name}`;
}
export const PI = 3.14159;
// Default export
export default function greet(name) {
return `Hello, ${name}`;
}
Importing
CommonJS:
// Import the entire export
const greet = require('./greet');
// Destructure multiple named exports
const { greet, farewell } = require('./utils');
// Core module
const fs = require('fs');
ES Modules:
// Default import
import greet from './greet.js';
// Named imports
import { greet, farewell } from './utils.js';
// Rename on import
import { greet as sayHello } from './utils.js';
// Import everything into a namespace
import * as utils from './utils.js';
// Core module
import fs from 'node:fs';
The most immediately noticeable difference in ESM import paths: file extensions are required. import { greet } from './utils' will throw an error in an ES Module — it must be './utils.js'. CommonJS does not require extensions.
Static vs Dynamic Structure
This is the difference with the deepest practical consequences.
CommonJS require() is dynamic. You can call it anywhere — inside a function, inside a conditional, inside a loop. The path you pass to it can be a variable computed at runtime:
// Perfectly valid CommonJS
const moduleName = condition ? './moduleA' : './moduleB';
const mod = require(moduleName);
function loadPlugin(name) {
return require(`./plugins/${name}`);
}
ESM import statements are static. They must appear at the top level of a module, outside any function or conditional. The import path must be a string literal — not a variable, not a template expression:
// Valid
import { greet } from './greet.js';
// Invalid — import inside a function
function setup() {
import { greet } from './greet.js'; // SyntaxError
}
// Invalid — dynamic path
import { greet } from modulePath; // SyntaxError
This is not an oversight. The static structure of ESM imports is intentional — it is what allows JavaScript engines and bundlers to analyse the dependency graph before any code runs. This static analysis is what makes tree-shaking possible: a bundler can determine exactly which exports are used and eliminate the rest from the final bundle, something that is impossible when dependencies are resolved at runtime.
For cases where dynamic loading is genuinely needed in ES Modules, the import() function is available. It is dynamic, returns a Promise, and works inside functions and conditionals:
async function loadPlugin(name) {
const plugin = await import(`./plugins/${name}.js`);
return plugin.default;
}
this at the Top Level
A subtle but occasionally surprising difference involves what this refers to at the top level of a module file.
In CommonJS, this at the top level of a file refers to module.exports — the object being built up as the module's export:
// CommonJS
console.log(this === module.exports); // true
console.log(this); // {}
In ES Modules, this at the top level is undefined:
// ES Module
console.log(this); // undefined
This rarely causes problems in practice, but it surfaces in libraries that use top-level this to detect their environment.
__dirname and __filename
CommonJS provides two convenience variables in every module automatically:
console.log(__dirname); // /home/wariz/project
console.log(__filename); // /home/wariz/project/app.js
These do not exist in ES Modules. The equivalent must be derived from import.meta.url — a URL pointing to the current module file:
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
This is verbose, and it is one of the genuine ergonomic regressions of moving to ESM in Node.js. The import.meta.url approach is the correct solution — it just requires more boilerplate than the CommonJS equivalent.
In Node.js 21.2.0 and later, import.meta.dirname and import.meta.filename are available directly, eliminating the need for this workaround:
// Node.js 21.2.0+
console.log(import.meta.dirname); // /home/wariz/project
console.log(import.meta.filename); // /home/wariz/project/app.js
Top-Level await
One of the most practically useful features of ES Modules is top-level await — the ability to use await at the top level of a module file, outside any async function:
// ES Module — valid
const config = await fetch('https://api.example.com/config').then(r => r.json());
export default config;
This is not possible in CommonJS because require() is synchronous. There is no mechanism for the module loader to wait on a Promise during a require() call. The common workaround in CommonJS is to export a factory function or initialisation function that the consumer calls and awaits:
// CommonJS workaround
async function init() {
const config = await fetch('https://api.example.com/config').then(r => r.json());
return config;
}
module.exports = init;
// Consumer
const init = require('./config');
const config = await init();
Top-level await is cleaner and is one of the concrete ergonomic improvements ES Modules brings to Node.js.
Interoperability
The two systems can coexist in a project, but mixing them comes with constraints.
Loading CommonJS from an ES Module
An ES Module can import a CommonJS module. Node.js treats the CommonJS module.exports value as the ESM default export:
// utils.cjs (CommonJS)
module.exports = { greet, farewell };
// app.mjs (ES Module)
import utils from './utils.cjs';
// utils = { greet, farewell }
// Named imports from CommonJS are not always reliable
import { greet } from './utils.cjs'; // may not work for all CJS exports
Named imports from CommonJS modules are unreliable because Node.js cannot statically analyse CommonJS exports — the module.exports object is built up at runtime. Importing the whole export and destructuring it manually is safer.
Loading an ES Module from CommonJS
A CommonJS module cannot use require() to load an ES Module. Because require() is synchronous and ES Modules load asynchronously, they are fundamentally incompatible at the loader level:
// This will throw an error
const mod = require('./utils.mjs'); // ERR_REQUIRE_ESM
The solution is to use a dynamic import() instead, which handles the asynchronous nature of ESM correctly:
// CommonJS file loading an ES Module
async function run() {
const mod = await import('./utils.mjs');
mod.default.greet('Wariz');
}
run();
This is an asymmetric relationship — ESM can consume CJS, but CJS cannot require() ESM. It is the main constraint that makes gradual migration from CommonJS to ES Modules non-trivial in large projects.
The "Pure ESM" Problem
Some npm packages have migrated to publishing only ES Modules — no CommonJS build. This is sometimes called going "pure ESM". When a CommonJS project depends on a pure ESM package, the project cannot require() that package directly, and migrating to ESM to resolve the dependency may be a significant undertaking.
Notable packages that have gone pure ESM include node-fetch v3, chalk v5, and got v12. Each of these maintains an older CommonJS-compatible version alongside the pure ESM release for exactly this reason.
If you encounter ERR_REQUIRE_ESM in a project, this is the most common cause: a dependency has moved to pure ESM and your project is still CommonJS. The options are:
- Pin the dependency to an older CommonJS-compatible version
- Use dynamic
import()to load it from your CommonJS code - Migrate your project to ES Modules
Choosing Between Them
| Situation | Recommendation |
|---|---|
| New Node.js project | ES Modules — set "type": "module" in package.json |
| Existing CommonJS project | Stay CommonJS unless there is a specific reason to migrate |
| Publishing an npm package | Ship both — a CommonJS and an ES Module build |
| Using a framework (Express, NestJS, etc.) | Follow the framework's convention |
Hitting ERR_REQUIRE_ESM | Use dynamic import() or pin the dependency |
There is no universally correct answer. The ecosystem is in transition and both systems are legitimate. The pragmatic position is to use ES Modules for new work — it is the standard, it has better tooling support long-term, and it eliminates the interoperability problems that come from running a mixed project — while respecting the constraints of the codebase you are actually working in.
Summary
| CommonJS | ES Modules | |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Loading | Synchronous | Asynchronous |
| Structure | Dynamic — require() anywhere | Static — import at top level only |
| File extensions in paths | Optional | Required |
__dirname / __filename | Built-in | Requires import.meta.url workaround (or import.meta.dirname in Node 21.2+) |
Top-level await | Not supported | Supported |
| Tree-shaking | Not possible | Possible |
| Default in Node.js | Yes | Opt-in via "type": "module" or .mjs |
| Can load the other? | CJS cannot require() ESM | ESM can import CJS |
| Dynamic loading | require() natively | import() function |