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 usesimport/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.
__dirnamedoes not exist in ESM; you reconstruct it fromimport.meta.url
Quick example
// 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)); // 5The 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
| Feature | CommonJS | ES Modules |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Loading | Synchronous, runtime | Static, resolved before run |
| Tree-shaking | No | Yes |
Top-level await | No | Yes |
__dirname | Available | Use import.meta.url |
| Default in Node.js | Yes (.js files) | .mjs or "type":"module" |
| CJS imports ESM | Only via await import() | N/A |
| ESM imports CJS | Yes (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) inpackage.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:
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
// 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
// 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'; // CorrectNode.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
// math.mjs
const fs = require('fs'); // ReferenceError: require is not defined in ES module scoperequire 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 inpackage.jsonhandles 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:
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}Examples
Basic export and import: both systems side by side
// --- 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)); // 12Both 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
// 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
// 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 readyA concise answer to help you respond confidently on this topic during an interview.