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.
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);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.
| 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.