require(esm) — CommonJS synchronously requiring an ES Module — is stable in current Node, confirmed directly against this app's installed runtime. A lot of "you simply can't do that" advice about mixing module systems is now outdated for the common case, though modules using top-level await still can't be required synchronously.
commonjsesmmodulesfsrequire-esm
Knowledge Check
15 questions · pass at 70%
0/15
easymcq
1. What determines whether a .js file is interpreted as CommonJS or ESM?
Interview Questions
2 questions
Cheatsheet
Download-ready reference
Cheatsheet
TWO module systems in Node: CommonJS (require/module.exports,
SYNCHRONOUS resolution) and ESM (import/export, ASYNC module
graph — see Modules).
Determined by: nearest package.json's "type" field
("commonjs"/absent → CJS default, "module" → ESM default)
OVERRIDE per-file: .cjs always CJS, .mjs always ESM.
require(esm): STABLE in current Node for the common case.
⚠️ Exception: a module using TOP-LEVEL AWAIT cannot be required
synchronously (structurally incompatible with require()'s
synchronous contract).
THREE fs API styles, same underlying operation:
fs.readFileSync(...) → BLOCKS the main thread entirely
fs.readFile(cb) → non-blocking, thread pool, callback
(await) fs/promises.readFile() → non-blocking, thread pool, promise
readFileSync: fine for ONE-TIME STARTUP reads (nothing else
concurrent yet). NEVER inside a request-handling path — it stalls
EVERY concurrent request, not just the one that called it.
Module state persists across require()/import calls — a module is
evaluated ONCE per process, not re-run on every import.
The beginner framing: Node.js has always had a way to split code into files and load them with require() — and more recently, standard JavaScript's own import/export syntax works too.
The precise mental model: Node supports two distinct module systems — CommonJS (require/module.exports, Node's original system, resolved synchronously) and ECMAScript Modules (import/export, the JavaScript-standard system, resolved as an async graph, covered generally in Modules). Which one a given .js file uses is determined by the nearest package.json's "type" field — "type": "commonjs" (or no field at all) means CommonJS; "type": "module" means ESM. The .cjs/.mjs extensions override this explicitly, regardless of "type".
// package.json: { "type": "module" }// math.js — interpreted as ESM because of "type": "module"export function add(a, b) { return a + b; }// legacy.cjs — ALWAYS CommonJS, regardless of "type"module.exports = { legacy: true };
require(esm): a genuinely new capability, confirmed stable#
For a long time, a hard rule existed: CommonJS's synchronous require() could not load an ES Module, since ESM resolution is asynchronous by design. This has changed — confirmed directly against this app's installed Node v23.11.0 (process.features.require_module === true), require() can now synchronously load most ES Modules. The remaining caveat: a module using top-level await still can't be required synchronously, since there's no way to synchronously wait for that module's own async initialization to finish.
// mathEsm.mjsexport const double = (x) => x * 2;// app.cjsconst { double } = require("./mathEsm.mjs"); // works — stable in current Node
const fs = require("fs");// 1. Synchronous — BLOCKS the entire main thread until doneconst data1 = fs.readFileSync("file.txt", "utf8");// 2. Callback-based — non-blocking, uses the libuv thread poolfs.readFile("file.txt", "utf8", (err, data2) => { /* ... */ });// 3. Promise-based — non-blocking, works naturally with async/await
All three read the same file, but only the first one blocks the main thread — the other two hand the actual I/O off to libuv's thread pool (see Runtime Overview) and only run their callback/resolve their promise once the data is ready.
Given that require(esm) is now stable, what does this log?
Solution
1, then 2. Since counter.mjs has no top-level await, require() can load it synchronously — and just like any module system, the module's state (count) persists across multiple require()/import calls within the same process, since it's the same loaded module instance being reused, not re-evaluated from scratch each time.
Build a tiny demonstration of why blocking the main thread with readFileSync is costly, contrasted with the async alternatives:
function simulateRequestHandling(readStrategy) { const log = []; log.push("request A arrives"); if (readStrategy === "sync") { log.push("readFileSync BLOCKS — nothing else can happen until it returns"); log.push("(file read completes, main thread was frozen the whole time)"); log.push("request B, which arrived DURING the block, only NOW gets processed");
This mirrors the exact tradeoff from Runtime Overview: synchronous APIs run on the one main thread and block everything; asynchronous APIs offload the actual work, keeping the main thread free.
ESM's static import/export graph (enabling tree-shaking, since imports/exports are known ahead of time, unlike CommonJS's dynamic, runtime require() calls) is the same distinction covered in Modules — Node's dual-module-system reality is this same JS-wide distinction, just with Node choosing per-file which system applies. And fs.promises' promise-based API is a direct application of Promises/Async, Await — wrapping the same underlying thread-pool-backed operation in a promise instead of a callback, changing only the consumption ergonomics, not the underlying concurrency behavior.
This was true in older Node versions, and a lot of existing content/answers still assume it — but it's stable in current Node for the common case (no top-level await in the required module).
This blocks the entire main thread — and therefore every other concurrent request, not just this one — for the duration of the read. readFileSync is appropriate for one-time startup reads (loading config before the server starts accepting requests), never inside a request handler.
Prefer fs/promises with async/await for new code over callback-style fs — cleaner error handling, no callback nesting.
Reserve readFileSync/other sync APIs for startup-time, one-off work, never inside a request-handling path.
Be explicit about your module system — set "type" in package.json deliberately, and use .cjs/.mjs extensions when a file genuinely needs to override the default.
Don't assume is unavailable without checking your actual Node version and the specific module (top-level is the one real remaining blocker).
A blocking fs call inside a request handler doesn't just slow down that one request — it freezes the entire server for every concurrent request during that time, exactly the CPU-bound-blocking lesson from Runtime Overview, just triggered by I/O instead of computation.
Async fs operations offload actual disk I/O to libuv's thread pool, keeping the main thread free to keep handling other requests concurrently — this is the entire reason the async APIs exist.
const { readFile } = require("fs/promises");
const data3 = await readFile("file.txt", "utf8");
}
else
{
log.push("readFile (async) hands work to the thread pool, main thread stays free");
log.push("request B arrives and is processed IMMEDIATELY, concurrently");
log.push("(file read completes on the thread pool, callback fires later)");
}
return log;
}
simulateRequestHandling("sync");
// request B has to wait for the ENTIRE file read to finish first
simulateRequestHandling("async");
// request B is handled immediately, unaffected by the in-flight file read