Node.js Runtime and Modules: ESM vs CommonJS Explained
Web DevelopmentLesson 1 of 46 min read
What you'll learn
- Describe what Node.js is made of and what it adds to plain JavaScript
- Write the same module with
import/exportand withrequire/module.exports - Predict which module system Node uses for a
.js,.mjsor.cjsfile - Diagnose the errors you get when the two systems are mixed
On this page
Two files sit in the same folder. One starts with import fs from "node:fs" and runs fine. The other starts with const fs = require("node:fs") and also runs fine. Rename the first so it ends in .cjs and it crashes with a message about import statements. Nothing about the language changed, yet the same line of code is legal in one file and fatal in another. This lesson explains why, and gives you a way to reason about it instead of guessing.
What you are actually running
Node.js is not a language and not a framework. It is a program that embeds Google's V8 engine (the part that understands JavaScript) and surrounds it with a library of system APIs written in C++ and JavaScript: files, network sockets, timers, child processes, cryptography. A native layer called libuv handles waiting for the operating system so that your JavaScript does not have to. You can see the difference immediately: there is a global process object and a global fetch, but no window and no document.
Everything beyond the language itself arrives through modules. The built-in ones are imported with a node: prefix (node:fs, node:path, node:http). The prefix is optional for most of them, but it makes it obvious that you are importing something that ships with Node rather than something from node_modules, and it protects you if an npm package ever shares a name with a built-in. Your own code is split into modules too, and that is where the two systems come in.
A fare calculator as an ES module
Imagine a ticketing tool that prices a journey by the number of zones crossed. The pricing rule lives in one file and the command-line entry point in another. ES modules (ESM) are the standard JavaScript module format, the same one browsers use, so this is the style to prefer for new code.
const BASE_FARE = 12;
const PER_EXTRA_ZONE = 8;
export const zones = ["A", "B", "C", "D"];
export function fareFor(zoneCount, { peak = false } = {}) {
const raw = BASE_FARE + (zoneCount - 1) * PER_EXTRA_ZONE;
return peak ? Math.round(raw * 1.25) : raw;
}
export default function describe(zoneCount, options) {
return `${zoneCount} zone(s) -> ${fareFor(zoneCount, options)}`;
}import describe, { fareFor, zones } from "./fare.mjs";
import * as fare from "./fare.mjs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.basename(fileURLToPath(import.meta.url));
console.log("running", here);
console.log(describe(2));
console.log(describe(3, { peak: true }));
console.log(zones.length, "zones, cheapest fare:", fareFor(1));
console.log(Object.keys(fare));
console.log(typeof require, typeof __dirname);Output
running main.mjs 2 zone(s) -> 20 3 zone(s) -> 35 4 zones, cheapest fare: 12 [ 'default', 'fareFor', 'zones' ] undefined undefined
Read the last two lines of output closely. Object.keys(fare) lists default, fareFor and zones: a module namespace is a live view of everything the file exported, and the default export is just one more entry called default. And typeof require and typeof __dirname both print undefined. Inside an ES module those CommonJS helpers do not exist. The replacement for knowing where your file lives is import.meta.url, a file:// URL that you can convert with fileURLToPath, or hand straight to APIs such as readFile through new URL("./data.json", import.meta.url).
ESM also gives you two things CommonJS cannot: static imports (Node can read the import lines before running any code) and top-level `await`, so a script can await a file read at its first line without wrapping everything in an async function.
The same idea in CommonJS
CommonJS (CJS) is the original Node format, from before JavaScript had modules at all. A file assigns what it wants to share to module.exports, and consumers call require, which runs the file synchronously the first time and then returns a cached copy.
const BASE_FARE = 12;
function fareFor(zoneCount) {
return BASE_FARE + (zoneCount - 1) * 8;
}
module.exports = { fareFor, label: "fare.cjs" };
console.log("loaded from", require("node:path").basename(__filename));const { fareFor, label } = require("./fare.cjs");
require("./fare.cjs"); // second require: cached, no second log line
console.log(label, fareFor(4));
import("./fare.mjs").then(({ default: describe }) => {
console.log(describe(4));
});Output
loaded from fare.cjs fare.cjs 36 4 zone(s) -> 36
The log line "loaded from fare.cjs" appears once even though the file was required twice: that is the module cache at work, and it is why a CommonJS module can safely hold a single shared database connection. Notice also the import("./fare.mjs") call at the bottom. A dynamic import() is available in both systems, returns a promise, and is the always-safe way for CommonJS code to reach an ES module.
How Node picks one for each file
Node decides per file, using the extension first and the nearest package.json second.
| File | Treated as | Notes |
|---|---|---|
.mjs | ES module | Always, regardless of any package.json |
.cjs | CommonJS | Always |
.js with "type": "module" in the nearest package.json | ES module | The usual setting for a modern project |
.js with "type": "commonjs" or no type | CommonJS by default | Recent Node 22 releases will inspect the file and switch to ESM if it contains import/export syntax; older versions throw a SyntaxError instead |
Crossing the border between the two
Real projects mix both because the npm ecosystem is split. What works depends on the direction, and on the Node version, so here is what I observed on Node 22.15 using the files above.
| Direction | Result on Node 22.15 | What to do |
|---|---|---|
| ESM imports CJS (default import) | Always works: import mod from "./fare.cjs" gives module.exports | Use this form when unsure |
| ESM imports CJS (named import) | Works only when Node can see the export names statically, e.g. module.exports = { fareFor }. It fails when exports are built dynamically | Fall back to the default import and destructure |
| CJS requires ESM | Works in this version for a plain module; fails with ERR_REQUIRE_ASYNC_MODULE if the ESM graph uses top-level await. Older Node 22 releases refused it outright | Use await import() when you cannot be sure |
Because that last row has changed between releases, never assume. Check node --version on the machine that runs your code, and keep the production version pinned.
Common mistakes and the errors they produce
- Using `require` in an ES module. Symptom:
ReferenceError: require is not defined in ES module scope, you can use import instead. The message even tells you why the file is ESM (the.jsextension plus"type": "module"). Fix: switch toimport, or rename the file.cjs. - Using `__dirname` or `__filename` in an ES module. Symptom:
ReferenceError: __dirname is not defined in ES module scope. Fix:fileURLToPath(new URL(".", import.meta.url))or passnew URL(...)objects to the file APIs directly. - Leaving the extension off a relative ESM import. Symptom:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../fare' imported from .../noext.mjs. CommonJS guessed.jsfor you; ESM does not. Always write./fare.mjsor./fare.js. - Named import from a CommonJS file whose exports are built at runtime. Symptom:
SyntaxError: Named export 'fareFor' not found. The requested module './dyn.cjs' is a CommonJS module. Node prints the workaround in the message: default-import the module and destructure it. - Requiring an ES module that uses top-level await. Symptom:
ERR_REQUIRE_ASYNC_MODULE. Fix:import()it.
ReferenceError: require is not defined in ES module scope, you can use import instead
This file is being treated as an ES module because it has a '.js' file extension
and '.../package.json' contains "type": "module". To treat it as a CommonJS script,
rename it to use the '.cjs' file extension.
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../fare' imported from .../noext.mjs
SyntaxError: Named export 'fareFor' not found. The requested module './dyn.cjs' is a
CommonJS module, which may not support all module.exports as named exports.How this shows up in real projects and interviews
In day-to-day work the module question mostly appears as friction: a library you installed ships only ESM and your old CommonJS service cannot require it, or a test runner or build tool expects one format. The practical skill is reading the error, knowing which of the two worlds each file lives in, and keeping a project consistent. In interviews, "what is the difference between require and import?" is a standard opener. A strong answer mentions synchronous versus static loading, the module cache, live bindings versus copied values, top-level await, and the per-file detection rules above, rather than just "one is newer". Next, the event loop explains when the code in your modules actually gets to run: Node.js Event Loop and Async Code.
Try it yourself
Create a small project with package.json containing { "type": "module" }. Write parcel-weight.js that exports a named function isOversize(kg) and a default function label(kg), and a report.js that imports both and prints a line for three weights. Then write a legacy.cjs that prints the result of label(12) by loading parcel-weight.js with dynamic import(). Finally, deliberately add console.log(__dirname) to report.js, read the error and replace it with the import.meta.url equivalent.
Show a hint
In legacy.cjs the dynamic import returns a promise, so use import("./parcel-weight.js").then(...). To rebuild __dirname, pass import.meta.url to fileURLToPath from node:url, then call path.dirname on the result.
Key takeaways
- Node.js is V8 plus a native I/O layer and a library of built-in modules, imported with the
node:prefix. - ES modules use
import/export, haveimport.meta.urland top-level await, and have norequire,__dirnameor__filename. - CommonJS uses
require/module.exports, loads synchronously and caches each module after its first run. - Extension and the nearest
package.jsontypedecide the format; settypeexplicitly instead of relying on syntax detection. - When mixing formats, default-import CJS from ESM and use dynamic
import()from CJS if you are not sure of the Node version.
Test yourself
A trick from this lesson's ride: Extension first, package.json second: a file's own suffix outranks the folder's default. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
What is the difference between require and import in Node.js?
require is the CommonJS loader: it runs synchronously, can be called anywhere, and returns a cached module.exports value. import is the standard ES module syntax: it is static, hoisted, supports live bindings and top-level await, and loads asynchronously. Which one is allowed in a file depends on whether Node treats that file as CommonJS or as an ES module.
How do I use import instead of require in Node.js?
Either add "type": "module" to the nearest package.json so .js files are ES modules, or name the file with the .mjs extension. Then replace const x = require("x") with import x from "x" and remember to include the file extension on relative imports such as ./fare.mjs.
Why is __dirname not defined in ES module scope?
__dirname and __filename are not globals. CommonJS wraps each file in a function that supplies them, and ES modules are not wrapped that way. Use import.meta.url instead: fileURLToPath(new URL(".", import.meta.url)) gives the directory, and new URL("./data.json", import.meta.url) can be passed directly to fs functions.