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" in package.json once per project rather than mixing .mjs/.cjs extensions 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 require and import in 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 to require it.
    • Use import.meta.dirname (Node 20.11+) instead of manually deriving a directory from import.meta.url when 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.js that exports a greet(name) function via module.exports, and a second file that requires it and logs a greeting.
    • Convert that same module to ES Modules: add "type": "module" to a package.json, rename the export to use export function greet(name), and update the importing file to use import with the correct file extension.
    • Write a small CommonJS script that uses the asynchronous import() function to load an ESM-only npm package (for example chalk) 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/.mjs extensions, or the nearest package.json‘s "type" field for plain .js files.
    • __dirname/__filename exist only in CommonJS; ESM uses import.meta.url or import.meta.dirname instead.
    • ESM relative imports require the file extension; CommonJS require calls do not.
    • Top-level await only works in ES Modules; CommonJS must wrap awaited code in an async function.
    • Cross-format interop is possible: ESM can import a CommonJS module directly, while CommonJS must use the asynchronous import() to load an ESM module.