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 number

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

Frequently Asked Questions

Why do I get "Cannot use import statement outside a module"? Node is treating the file as CommonJS, which has no import statement. Either add "type": "module" to the nearest package.json, or rename the file to .mjs. Be aware that the package.json change applies to every .js file in that folder tree, so any file still using require will break at the same time. Rename those to .cjs or convert them.
Can I use both require and import in the same file? No. A file is either CommonJS or an ES module, decided before it runs. In an ES module you can rebuild a require function with createRequire(import.meta.url) for interop with a stubborn package, and you can load an ES module from CommonJS with dynamic import(), which returns a promise. Those are workarounds, not a genuine mix of the two syntaxes in one scope.
What does "type": "module" actually change? It changes how Node interprets .js files under that package.json. They become ES modules, so require, module, exports, __dirname and __filename disappear, code runs in strict mode, top-level await becomes available, and relative imports need file extensions. It does not change .mjs or .cjs files, which always keep their own meaning.
Why did import { something } from a CommonJS package fail? Node cannot execute a CommonJS module to find out what it exports before wiring up an ESM import, so it statically scans the source and guesses the export names. If the package assigns to module.exports dynamically, in a loop, or behind a condition, the scan misses those names. Import the default export and destructure from it at runtime instead.
How do I know if a package is causing the dual-package hazard? The symptom is state that does not seem shared, or instanceof returning false for an object that is obviously the right type. Check whether the package resolves through both the import and require conditions in your app, and check the lockfile for two installed versions. Standardising your project on one module system removes most of these cases.