JavaScriptCommonJS vs ES Modules

CommonJS vs ES Modules

JavaScript has two module systems sitting on top of each other. CommonJS (require / module.exports) is what Node has shipped since the beginning and what older npm packages still use. ES Modules (import / export) are the standard built into the language since 2015. Modern Node supports both, browsers only support ES Modules, and many packages now ship both formats. Knowing the differences — and the gotchas at the boundary — saves a lot of debugging.

The two syntaxes side by side

CommonJS

JS
// math.cjs
function add(a, b) { return a + b; }
const PI = 3.14159;

module.exports = { add, PI };
// or: exports.add = add; exports.PI = PI;

// app.cjs
const { add, PI } = require("./math.cjs");
console.log(add(2, 3));

ES Modules

JS
// math.mjs
export function add(a, b) { return a + b; }
export const PI = 3.14159;

// app.mjs
import { add, PI } from "./math.mjs";
console.log(add(2, 3));
How Node decides which is which

Node looks at three things, in order:

  • File extension. .cjs is always CommonJS; .mjs is always ESM.

  • The nearest package.json "type" field. "type": "module" makes plain .js files ESM; "type": "commonjs" (the default) makes them CommonJS.

  • Per-file overrides win. A .cjs file inside a "type": "module" package is still CommonJS.

package.json — opt into ESM for plain .js

JSON
{
  "name": "my-app",
  "type": "module",
  "main": "src/index.js"
}
The real differences (not just syntax)
  • Load timing. CommonJS resolves at runtime — require runs synchronously the moment the line executes. ESM resolves statically at parse time, before any code runs. That is what allows tree shaking and top-level await.

  • Value vs binding. CommonJS exports are a copy of module.exports at the moment require returns. ESM exports are live bindings — re-assigning an exported let is visible to every importer.

  • Cycles. Both handle circular dependencies, but with different rules. CommonJS may hand you a half-filled object; ESM hands you an uninitialised binding (Temporal Dead Zone) if you touch it too early.

  • File extensions. Native ESM requires the full path including .js. CommonJS lets require("./math") find math.js, math/index.js, etc.

  • this at the top level. CommonJS: module.exports. ESM: undefined.

  • __dirname / __filename. Available in CommonJS for free. In ESM you build them from import.meta.url.

  • Async. ESM modules are inherently asynchronous — they can await at the top level. CommonJS is strictly synchronous.

The interop story

Most production headaches happen at the boundary.

Importing CommonJS from ESM (mostly works)

JS
// CJS export
// module.exports = { add, PI };

// In an ESM file:
import pkg from "./math.cjs";
const { add, PI } = pkg;

// Or named imports (Node detects them when it can):
import { add, PI } from "./math.cjs";

Importing ESM from CommonJS (must be async)

JS
// You cannot use require() for an ESM module — it is async.
async function main() {
  const { add } = await import("./math.mjs");
  console.log(add(2, 3));
}
main();
ERR_REQUIRE_ESM
If you see this in Node, it means a CommonJS file tried to `require` a pure-ESM package. Either switch your file to ESM, or use `await import()` instead of `require`. Recent Node versions are relaxing this restriction for some cases, but writing async-aware code is the safe path.
Dual packages

Many npm libraries ship both formats and let package.json decide which one a caller gets. The "exports" field is the modern way.

package.json — dual package

JSON
{
  "name": "my-lib",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    },
    "./package.json": "./package.json"
  }
}

Bundlers and Node pick the right entry automatically. Tools like tsup, unbuild and microbundle produce these dual builds for you.

__dirname and __filename in ESM

esm-paths.js

JS
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";

const __filename = fileURLToPath(import.meta.url);
const __dirname  = dirname(__filename);

console.log(__dirname);

Node 20+ also exposes import.meta.dirname and import.meta.filename directly, which removes the boilerplate.

Which should you write today?
  • New code — write ES Modules. They are the language standard, work in the browser, support top-level await, and play nicely with TypeScript.

  • Touching legacy Node? Stay with CommonJS until the surrounding code converts. Mixing inside one file is not possible.

  • Publishing a library? Ship dual — ESM as the main format, CJS for older consumers. A small build step is worth the compatibility.

  • Using TypeScript? Set "module": "NodeNext" (or "Bundler") in tsconfig.json and pick a "type" for package.json that matches your output.

One mental shortcut
If a runtime/tool wants you to think about a *module graph* at build time (bundlers, tree shaking, top-level await) — that is the ESM world. If it cares about *one file at a time*, executed sequentially with synchronous `require` — that is CommonJS. The two will continue to coexist for years; choose per project, not per file.