NodeJSCommonJS vs ES Modules

CommonJS vs ES Modules

Node now ships two module systems side by side, and choosing — or mixing — them is a real source of friction. This page lays them out feature by feature, explains the deep reason they behave differently, and gives concrete guidance for new projects and migrations.

Side by side

Aspect

CommonJS

ES Modules

Import syntax

require()

import

Export syntax

module.exports

export / export default

Loading

Synchronous, on demand

Asynchronous, statically resolved

When imports resolve

At runtime, where written

Hoisted, before execution

Conditional/dynamic import

Yes — require() is a function

Only via import() function

Bindings

Copies of values

Live, read-only bindings

Top-level await

No

Yes

__dirname / __filename

Provided

Use import.meta.url

File extension in imports

Optional

Required

Tree-shaking

Hard (dynamic)

Easy (static)

The root cause: static vs dynamic

Almost every row above flows from one design choice. CommonJS resolves modules at runtimerequire is just a function that runs when reached, so it can be conditional and synchronous but cannot be analyzed ahead of time. ESM resolves the entire graph statically, before executing any code, which enables tree-shaking and live bindings but forbids runtime-computed imports.

JS
// CommonJS — decided while running
const mod = require(condition ? './a' : './b')

// ESM — the static form cannot do that; use the dynamic function form
const mod = await import(condition ? './a.js' : './b.js')
How Node decides which system a file is

File

package.json type

Result

.cjs

anything

CommonJS

.mjs

anything

ES Modules

.js

"module"

ES Modules

.js

"commonjs" or absent

CommonJS

The explicit-extension escape hatch
Even inside a `"type": "module"` project, you can drop in a single `.cjs` file to use `require` for one legacy dependency — and vice versa. The extension always wins over `type`, so it is your per-file override.
Mixing the two
  • ESM → CJS: works. import pkg from "cjs-lib" gives you module.exports as the default. Named imports work when Node can statically detect them, but fall back to const { x } = pkg if not.

  • CJS → ESM: require() of an ESM file throws ERR_REQUIRE_ESM (ESM is async, require is sync). Use dynamic import() instead — it returns a promise.

  • Dual packages: libraries ship both builds via the "exports" field with "import" and "require" conditions, so each consumer gets the right one.

CommonJS reaching an ESM module

JS
// require('./esm-only.mjs')  → ERR_REQUIRE_ESM
const esm = await import('./esm-only.mjs')   // this works (async)
The dual-package hazard
If a package can load as *both* CJS and ESM, a program may end up with **two copies** of it — separate state, failing `instanceof` checks. Avoid storing singletons/identity-sensitive data in dual-published packages, or expose them through a single shared CJS core.
Which should you use?
Recommendation
For **new projects**, use **ESM** — it is the language standard, the direction the whole ecosystem is moving, and it unlocks tree-shaking and top-level await. Stay on **CommonJS** when you must support old Node versions, depend on packages that only work under CJS, or maintain a large existing CJS codebase where migration is not worth it.
Migration cheat-sheet

CommonJS

ES Module equivalent

const x = require("x")

import x from "x"

const { a } = require("x")

import { a } from "x"

module.exports = fn

export default fn

exports.a = ...

export const a = ...

__dirname

import.meta.dirname (or derive from import.meta.url)

require.resolve("x")

import.meta.resolve("x")

Next
Understand the optimization that makes `require`/`import` cheap to call repeatedly: [Module Caching](/nodejs/module-caching).