Node.js supports two module systems: CommonJS (require, module.exports) and ES modules (import, export). They differ in loading, timing and top-level behavior, and the interop rules between them decide whether an import works or throws.
Before you start
You should be comfortable with modules and package.json. This article covers interop rules; it assumes basic familiarity with both syntaxes.
Step-by-step walkthrough
Step 1: Declare the module system
Set "type": "module" in package.json to treat .js as ESM, or use explicit .mjs and .cjs extensions. Without the field, .js defaults to CommonJS. Mixing implies interop, so be deliberate about which files are which.
Step 2: Import CommonJS from ESM
ESM can import a CommonJS module: the default export maps to module.exports, and named exports are often available through static analysis. import pkg from 'cjs-pkg' is the reliable form; prefer the default import when unsure.
Step 3: Reach ESM from CommonJS with dynamic import
CommonJS cannot require an ES module synchronously, but const mod = await import('esm-pkg') works because dynamic import() returns a promise. Use it inside an async function, and note that ESM is evaluated once, on first import.
Worked scenario
The CommonJS file loads an ES module asynchronously.
// consumer.cjs
async function main() {
const { format } = await import('./formatter.mjs');
console.log(format(42));
}
main();Walk through the example
import() returns a promise, so main awaits the module namespace object and destructures format. If formatter.mjs fails to load, the promise rejects and the error surfaces on the await, which is easier to catch than a synchronous throw. The ES module runs at most once even if imported from several places.
Common mistake
Calling require on an ES module, which throws ERR_REQUIRE_ESM on older Node versions. Another is assuming __dirname and require exist in ESM; they do not, so use import.meta.url and createRequire when migrating.
Verify the behavior
Import a CommonJS package from an ESM file and confirm both default and named imports resolve. From CommonJS, require an ESM file and assert the error, then replace it with await import() and confirm it succeeds. Log the module load order to confirm ESM evaluates once.
Interview exercise
A shared logger.js is used by both an ESM entry point and a CommonJS script. How do you avoid a dual-package problem?
Answer and reasoning
Pick one module system for the package and expose a single format, or provide explicit conditional exports in package.json ("import" and "require" conditions) that map to matching builds. Running the same file under both systems can otherwise create two module instances with separate state, so keeping one instance per process is the goal.
Continue learning
See module boundaries in Node module resolution and package exports. Read the Node.js modules documentation and try the Node.js interview questions.