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 runtimerequire 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.