CommonJS and ES Modules

CommonJS and ES Modules Side by Side

Node loads a CommonJS file by wrapping its text in a function, calling it, and keeping whatever the function left on module.exports. The wrapper supplies the five identifiers that make CJS files look magical: exports, require, module, __filename and __dirname. Loading is synchronous and depth-first, and the result is cached under its resolved filename.

An ES module never runs through a wrapper. Node parses it, collects its import and export declarations without executing anything, resolves and loads the whole graph, links the bindings, then evaluates bottom-up. That two-phase design makes import hoisted, cycles survivable and top-level await possible -- and is why an ES module cannot be evaluated synchronously by an arbitrary caller.

Live bindings versus copied propertiesJavaScript
import { hits, hit } from './counter.js';
import { createRequire } from 'node:module';
const cjs = createRequire(import.meta.url)('./counter.cjs');
const { hits: copied } = cjs;
hit();
cjs.hit();
console.log('esm binding:', hits, '| cjs copy:', copied, '| cjs property:', cjs.hits);
Output
esm binding: 1 | cjs copy: 0 | cjs property: 1

hits is an imported binding, not a variable: it points at the exporting module's storage slot and reads 1 after hit() runs. copied is an ordinary property, frozen at the moment of destructuring, while cjs.hits works. That is why CommonJS packages tell you to keep the namespace object rather than pull pieces out of it.

How the two module systems differ inside the Node.js 2,131 loader
Concern CommonJS ES modules
Loading Synchronous, on demand Asynchronous graph, linked first
Own path __filename, __dirname import.meta.filename, .dirname
Entry check require.main === module import.meta.main
Resolve a specifier require.resolve() import.meta.resolve()
Load JSON require('./a.json') import a from './a.json' with { type: 'json' }
Module cache require.cache, writable Internal, not exposed

import.meta.filename and import.meta.dirname are stable as of Node 24.0.0 and 22.16.0; before that every ES module opened with a fileURLToPath(import.meta.url) incantation. import.meta.main (24.2.0) is still experimental.