What you'll learn
Quick Answer
CommonJS uses require and module.exports, loads files synchronously, and hands you a copy of whatever the module exported at that moment. ES modules use import and export, are parsed before any code runs, and give you live bindings. Node decides which system a .js file uses from the nearest package.json type field. You cannot switch a file between them halfway, and mixing the two across a dependency tree is where most real breakage happens.
Two module systems sharing one file extension
The confusion is not really about syntax. It is that a file called utils.js can be either kind of module, and nothing in the file tells you which. Node decides by looking at the nearest package.json above it. No type field, or "type": "commonjs", means every .js file in that folder tree is CommonJS. "type": "module" means every .js file there is an ES module.
That is why the single most common breakage in an Indian college project is this: someone pastes an import statement, hits Cannot use import statement outside a module, searches, adds "type": "module" to package.json, and now every other file in the project fails with require is not defined in ES module scope. One line flipped the whole tree.
// package.json
{
"name": "fee-portal",
"type": "module"
}The two extensions that always win, whatever the type field says, are .mjs for an ES module and .cjs for CommonJS. Those are the escape hatches. If you need one legacy file inside an ESM project, rename it to .cjs and it keeps working.
The other visible difference in Node is specifiers. CommonJS lets you write require('./utils') and it will try utils.js, then utils/index.js. Node's ESM loader does not guess. You write the full path with the extension, import './utils.js', or you get ERR_MODULE_NOT_FOUND. Bundlers such as Vite and webpack do still resolve extensionless paths, so code that runs in your bundled frontend can fail when you run the same file directly with node.
Why you cannot mix them freely
require() is a function call that runs during execution, reads the file, executes it, and returns a value immediately. import is not a function call at all. ES module imports are part of the file's static structure. The engine parses the whole file first, builds the graph of dependencies, resolves them, and only then runs any code. This is why import statements are hoisted to the top and why you cannot put one inside an if block.
Because require() must return synchronously and an ESM graph may need asynchronous resolution, require() of an ES module historically threw ERR_REQUIRE_ESM. Newer Node versions have added limited support for requiring an ES module when its graph contains no top-level await. Do not design around that unless you control which Node version runs your code. Going the other way is fine: an ES module can always import a CommonJS file.
The deeper difference is what you receive. ESM exports are live bindings pointing at the exporting module's variable. CommonJS gives you the value that was on module.exports at the moment you required it.
// counter.mjs
export let count = 0;
export function increment() { count += 1; }
// main.mjs
import { count, increment } from './counter.mjs';
increment();
console.log(count); // 1 - the binding is live// counter.cjs
let count = 0;
module.exports = { count, increment() { count += 1; } };
// main.cjs
const { count, increment } = require('./counter.cjs');
increment();
console.log(count); // 0 - destructuring copied a numberThat second result is not a bug, and it burns people who assume a counter or a config flag will update. If a CommonJS module needs to expose changing state, export a getter function, not the value.
What disappears when you switch to ESM
Turning on "type": "module" removes five globals that CommonJS code leans on: require, module, exports, __dirname and __filename. None of them exist in an ES module. Every file-path helper written for a tutorial from a few years ago will fail.
The replacement is import.meta.url, which holds the current module's URL, not a path. Convert it before handing it to anything in node:path or node:fs.
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const uploads = path.join(__dirname, 'uploads');Newer Node versions also expose import.meta.dirname and import.meta.filename directly, which is cleaner when you know your runtime supports it. If you still need require for one stubborn package, build one:
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacyConfig = require('./legacy-config.cjs');Importing CommonJS from ESM works, but named exports are not guaranteed. Node runs a static scan over the CommonJS source to guess which names it exports. If the module assigns exports in a loop or behind a condition, the scan cannot see them and import { Router } from 'some-pkg' throws does not provide an export named. The reliable fix is to import the default and destructure at runtime:
import pkg from 'some-cjs-package';
const { Router } = pkg;ES modules also always run in strict mode, have top-level await, and support import(), which returns a promise and is the only way to load a module conditionally.
The dual-package problem
Library authors want one package to work for both audiences, so they ship two builds: a CommonJS file and an ESM file. The exports map tells Node which one to serve depending on how the caller asked for it.
{
"name": "receipt-utils",
"type": "module",
"main": "./dist/index.cjs",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}This works until both paths are taken in the same program. Your application imports the package, one of your dependencies requires it, and Node loads both files. They are separate modules with separate scopes, so you now have two copies of the library in memory.
That is the dual-package hazard, and it is silent. Anything the library keeps at module level is duplicated: a connection pool, a registry of plugins, a memoisation cache, a counter of issued invoice numbers. Writes made through one copy are invisible to the other. Worse, instanceof stops working, because the class object from the ESM copy is a different object from the class in the CommonJS copy. A perfectly valid error object fails err instanceof ApiError, so your handler falls through to the generic branch.
Two things reduce the risk. As a consumer, keep one style per project rather than mixing, and if a package misbehaves check whether it appears twice in your lockfile. As an author, keep the two builds thin and put shared state in a single CommonJS file that both builds require, or publish ESM only and let consumers on old runtimes use dynamic import(). The important part is recognising the symptom, because the error it produces never mentions modules.
Note that the exports map is also a lock. Once a package declares one, deep paths like require('pkg/lib/internal.js') stop resolving unless the author listed them.
Which one to use, and in the browser
For anything new, write ES modules. It is the format defined by the language itself, it works in browsers without a build step, and it is what every current tool assumes by default. CommonJS remains everywhere in existing Node code and is not going away, so you still need to read it fluently.
In the browser the choice is made for you. Only ES modules are supported natively, through type="module". A module script is deferred automatically, executes only once even if included twice, and is fetched with CORS rules, which is why opening an HTML file over file:// gives a CORS error while the same file works from a local server.
<script type="module" src="/js/app.js"></script>Browsers also refuse bare specifiers. import dayjs from 'dayjs' is meaningless to a browser because there is no node_modules lookup. Either use a real path, or declare an import map. A bundler normally does this translation for you, which is one reason frontend code and plain Node code behave differently even with identical syntax.
A short checklist that avoids most of the pain. Decide the module system before you write the first file, and put the type field in package.json deliberately rather than reactively. Use .cjs or .mjs for the odd file that must differ. Always write file extensions in relative imports so the same code runs under Node and under a bundler. If you use TypeScript, set module and moduleResolution to match what you will actually ship, because TypeScript compiles happily to output that Node then refuses to load.
