Skip to main content

CommonJS vs es modules in Node.js

CommonJS vs ES Modules - Node.js has two module systems, and picking the wrong one causes real headaches at runtime.

Theory

TL;DR

  • CommonJS uses require() / module.exports; ESM uses import / export
  • CommonJS loads synchronously at runtime; ESM is resolved statically before any code runs
  • CJS cannot require() an ESM file directly; ESM can import CJS (default export only)
  • New project? Use ESM. Maintaining older code? CJS is still fine.
  • __dirname does not exist in ESM; you reconstruct it from import.meta.url

Quick example

js
// CommonJS const { add } = require('./math'); // file read at this line console.log(add(2, 3)); // 5 // ES Modules import { add } from './math.mjs'; // resolved before execution starts console.log(add(2, 3)); // 5

The syntax difference is obvious. What is less obvious is when each file actually gets read. CommonJS reads the file the moment require() is called. ESM resolves all imports before any line of your code runs.

Key difference

CommonJS was designed for server-side code where disk reads are fine and modules load one at a time. ESM was designed for the browser, where load order matters and bundlers need to know statically which exports a file has. Node.js adopted ESM later, so both systems coexist today with their own separate module caches. Loading the same file once as CJS and once as ESM gives you two different module instances - a source of subtle bugs when mixing both.

Comparison table

FeatureCommonJSES Modules
Syntaxrequire() / module.exportsimport / export
LoadingSynchronous, runtimeStatic, resolved before run
Tree-shakingNoYes
Top-level awaitNoYes
__dirnameAvailableUse import.meta.url
Default in Node.jsYes (.js files).mjs or "type":"module"
CJS imports ESMOnly via await import()N/A
ESM imports CJSYes (default export only)N/A

When to use

  • New package or app: use ESM. You get tree-shaking, top-level await, and alignment with browser JavaScript.
  • Library targeting Node.js below 12: CommonJS still makes sense.
  • Mixed codebase: ESM can import CJS files, so you can migrate gradually without rewriting everything at once.
  • Publishing a dual-format package: ship both "main" (CJS) and "exports" (ESM) in package.json.

How Node.js decides which system to use

Node.js checks the file extension first, then package.json. A .cjs file is always CommonJS. A .mjs file is always ESM. A plain .js file follows the "type" field in the nearest package.json. No "type" field means "type": "commonjs" by default.

The two systems have separate module caches. That is why singleton patterns behave differently across them.

Getting __dirname in ESM

CommonJS injects __dirname and __filename automatically. ESM does not. You reconstruct them:

js
import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);

This boilerplate shows up often when migrating existing Node.js code to ESM.

Interoperability

js
// ESM importing CJS - works, but gets only the default export import mathModule from './math.cjs'; // CJS trying to require() an ESM file - throws at runtime const { add } = require('./math.mjs'); // Error: require() of ES Module not supported // CJS loading ESM with dynamic import - works const { add } = await import('./math.mjs');

The one-way restriction matters in practice. If you ship an ESM-only package, every CJS consumer must switch to await import(), which forces async context. Some teams find this friction reason enough to stay on CJS a while longer. That is why many popular packages like lodash and axios still ship CJS or dual-format.

Common mistakes

1. Forgetting the file extension in ESM imports

js
// CommonJS - extension optional, Node resolves it const math = require('./math'); // ESM - extension required import { add } from './math'; // Error: Cannot find module import { add } from './math.js'; // Correct

Node.js does not guess extensions in ESM mode. Browsers never did either. ESM in Node.js follows the same rule.

2. Using require() inside an .mjs file

js
// math.mjs const fs = require('fs'); // ReferenceError: require is not defined in ES module scope

require does not exist in ESM scope. Use import, or if you absolutely need it, use createRequire from the built-in module package.

3. Adding "type": "module" without renaming CJS files

If you add "type": "module" to package.json, every .js file in that directory becomes ESM. Any file still using require() breaks immediately. Rename those files to .cjs before flipping the flag.

4. Circular dependencies with different timing

CommonJS handles circular require() by returning a partial export at the moment of the cycle. ESM uses live bindings, so values update when the exporting module eventually sets them. Both work, but the timing differs. Circular dependencies in either system tend to produce confusing bugs.

Real-world usage

  • React projects with Vite: ESM by default, tree-shaking included
  • Express apps: mostly CommonJS, migrating gradually with "type":"module"
  • Node.js CLI tools: often CommonJS for compatibility with older tooling
  • Dual-format npm packages: the "exports" field in package.json handles both

Follow-up questions

Q: Can you use top-level await in CommonJS?
A: No. await in CommonJS only works inside an async function. Top-level await is ESM-only.

Q: What happens if two packages load the same CJS module?
A: CommonJS caches modules by resolved file path, so both get the same instance. That is why singleton patterns work reliably in CJS.

Q: Why do some packages still not support ESM?
A: Publishing an ESM-only package breaks all CJS consumers unless they switch to await import(). Many maintainers ship dual-format to avoid that.

Q: How does TypeScript relate to CommonJS and ESM?
A: TypeScript compiles to whatever you configure in tsconfig.json. "module": "CommonJS" outputs CJS. "module": "NodeNext" outputs ESM. The TypeScript source syntax looks like ESM regardless of the output target.

Q: What is a dual-format package?
A: A package that ships both CJS and ESM builds. The "exports" field in package.json tells Node.js which file to use based on whether the consumer uses require() or import:

json
{ "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" } } }

Examples

Basic export and import: both systems side by side

js
// --- CommonJS --- // math.js function multiply(a, b) { return a * b; } module.exports = { multiply }; // app.js const { multiply } = require('./math'); console.log(multiply(3, 4)); // 12 // --- ES Modules --- // math.mjs export function multiply(a, b) { return a * b; } // app.mjs import { multiply } from './math.mjs'; console.log(multiply(3, 4)); // 12

Both work. The difference shows up when a bundler processes your code: ESM lets it drop multiply if you never call it. CommonJS cannot do that because the bundler cannot know statically what you will use at runtime.

Migrating an Express route file to ESM

js
// Before (CommonJS) const express = require('express'); const { getUser } = require('./services/user'); const router = express.Router(); router.get('/user/:id', async (req, res) => { const user = await getUser(req.params.id); res.json(user); }); module.exports = router; // After (ESM) - add "type": "module" to package.json import express from 'express'; import { getUser } from './services/user.js'; // extension now required const router = express.Router(); router.get('/user/:id', async (req, res) => { const user = await getUser(req.params.id); res.json(user); }); export default router;

The logic is identical. Three things change: require becomes import, module.exports becomes export default, and file extensions in import paths become mandatory.

Dynamic import in a CommonJS file

js
// server.js (CommonJS) async function loadConfig() { // The only way CJS can load an ESM module const { parseConfig } = await import('./config.mjs'); return parseConfig(process.env); } loadConfig().then(config => { console.log('Server config:', config); });

This pattern appears when you have a CJS codebase but a dependency publishes ESM-only. Dynamic import() works in both module systems, so it bridges the gap without rewriting everything.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?