DeepFrontend
Practice ArenaBlogSign in
Go Pro
DeepFrontend

Interview-grade learning paths and hands-on practice for mid-to-senior engineers. Master internals, patterns, and system architecture with runnable code.

All systems operational

Core Courses

  • -JavaScript Internals
  • -React Reconciliation
  • -TypeScript rigor
  • -Next.js Caching
  • -Node.js Event Loop
  • -System Design
  • -Web Security
  • -CSS & Page Layouts

Explore

  • Learning Paths
  • Practice Arena
  • Pricing Options
  • HTML Sitemap

Resources

  • Privacy Policy
  • Terms of Service
  • Email Support

© 2026 DeepFrontend. All rights reserved.

Expert learning environments for web engineering teams.

Home/Node.js/Modules & File System
beginner20 min read·Updated Jun 2025

Modules & File System

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.

Related topics

Node.js Runtime OverviewModules (ESM, dynamic import)

On this page

20m
Knowledge CheckInterview QuestionsCheatsheet

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 Node

The 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

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












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 above

The .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/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).

Performance Tips#

  • 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
require(esm)
await