Concept
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.mjs
export const double = (x) => x * 2;
// app.cjs
const { double } = require("./mathEsm.mjs"); // works, stable in current NodeThe three flavors of fs
const fs = require("fs");
// 1. Synchronous, BLOCKS the entire main thread until done
const data1 = fs.readFileSync("file.txt", "utf8");
// 2. Callback-based, non-blocking, uses the libuv thread pool
fs.readFile("file.txt", "utf8", (err, data2) => { /* ... */ });
// 3. Promise-based, non-blocking, works naturally with async/await
const { readFile } = require("fs/promises");
const data3 = await readFile("file.txt", "utf8");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.
Try It
Predict what happens before checking the solution.
// counter.mjs
let count = 0;
export function increment() { return ++count; }
// app.cjs
const { increment } = require("./counter.mjs");
console.log(increment()); // ?
console.log(increment()); // ?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.
Implement It Yourself
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");
} 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)"
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.
Under the Hood
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.
Common Mistakes
1. Assuming every .js file is CommonJS
// package.json
{ "type": "module" }// index.js, this is ESM, NOT CommonJS, because of the package.json aboveThe .js extension alone doesn't determine the module system, the nearest package.json's "type" field does, unless overridden by .cjs/.mjs.
2. Assuming require(esm) still always throws
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).
3. Using readFileSync in a hot request path
app.get("/config", (req, res) => {
const config = fs.readFileSync("./config.json"); // ❌ blocks EVERY concurrent request
res.json(JSON.parse(config));
});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.
Best Practices
- Prefer
fs/promiseswithasync/awaitfor new code over callback-stylefs, 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"inpackage.jsondeliberately, and use.cjs/.mjsextensions 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).
Performance Tips
- A blocking
fscall 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
fsoperations 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.
