Node.js NotesRohit’s interview study guide
Chapter 05

Modules

CommonJS and ES modules in Node — loading, caching, shared state, resolution and circular dependencies.

9 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#CommonJS: require and module.exports

CommonJS is the module system Node shipped with: a file exports by putting something on module.exports, and another file gets it with require(). The JavaScript site's CommonJS topic covers the syntax and the exports vs module.exports trap. This topic is about what Node is actually doing when you call require.

The key idea: require is an ordinary, synchronous function that runs another file and hands you whatever that file left in module.exports. Nothing is analysed ahead of time. When execution reaches a require call, Node works out which file it means, runs that file from top to bottom (unless it already ran), and returns. The mental model is a function call that happens to execute a whole file as its body.

To make that possible, Node never runs your CommonJS file as-is. It wraps the source in a function — (function (exports, require, module, __filename, __dirname) { … }) — and calls it. Everything that looks like a global in a CommonJS file is really one of those five parameters, which you can prove:

wrapper-demo.cjs
console.log(arguments.length);                 // the wrapper's five parameters
console.log(typeof require, typeof module, typeof exports);
console.log(this === module.exports, exports === module.exports);
console.log(__filename.split('/').pop(), __dirname.split('/').pop());
console.log('loaded while running?', module.loaded);
console.log('am I the entry point?', require.main === module);
setImmediate(() => console.log('loaded afterwards?', module.loaded));
AdvancedOutput: the five wrapper parameters
Output
5
function object object
true true
wrapper-demo.cjs cjs1
loaded while running? false
am I the entry point? true
loaded afterwards? true
What’s happening
  1. arguments.length is 5. Top-level code in a normal script has no arguments at all — it only exists here because your file is the body of a function. require('node:module').wrapper shows the exact wrapper text: '(function (exports, require, module, __filename, __dirname) { ' and '\n});'.
  2. require, module and exports are the wrapper's parameters, so typeof sees a function and two objects. The same wrapping is why a top-level const in one file never leaks into another — it's a local variable of that file's function.
  3. Node calls the wrapper with this set to module.exports, so top-level this === module.exports is true. exports starts as another reference to that same object, which is why exports.add = … works and exports = … doesn't.
  4. __filename and __dirname are the absolute path of this file and its folder (trimmed here to the last segment; the demo ran in a folder called cjs1).
  5. module.loaded is false while the file body is running and true once it has finished. That flag is what a circular require can observe — see Circular dependencies.
  6. require.main === module is true only for the file you started with node file.cjs. It's the CommonJS idiom for "run this only when executed directly, not when required" (Python's if __name__ == "__main__").
AdvancedRequire timing: math.cjs

Now watch the timing. Each require runs its file at the moment execution reaches it, and the caller waits:

math.cjs
console.log('  math.cjs: running');
let calls = 0;
function add(a, b) {
  calls++;
  return a + b;
}
module.exports = { add, getCalls: () => calls };
console.log('  math.cjs: done');
Advancedapp.cjs: static and conditional require
app.cjs
console.log('app: before require');
const math = require('./math.cjs');
console.log('app: after require, got', Object.keys(math));
console.log('app: add(2, 3) =', math.add(2, 3), '| calls =', math.getCalls());

if (process.argv.includes('--report')) {
  const path = require('node:path');            // conditional, runtime require
  console.log('app: report dir', path.basename(__dirname));
}
AdvancedOutput: when each require runs
Output
$ node app.cjs --report
app: before require
  math.cjs: running
  math.cjs: done
app: after require, got [ 'add', 'getCalls' ]
app: add(2, 3) = 5 | calls = 1
app: report dir cjs1
What’s happening
  1. app.cjs starts and prints app: before require. Nothing from math.cjs has run yet — CommonJS doesn't look ahead.
  2. require('./math.cjs') resolves the path, finds no cached copy, creates a module object, and calls math.cjs's wrapper. app.cjs is paused inside that call, exactly as it would be inside any function call.
  3. math.cjs prints both of its lines, creates the private calls variable, and assigns module.exports = { add, getCalls }. When the wrapper returns, require returns that object.
  4. Back in app.cjs, math.add(2, 3) returns 5 and bumps calls from 0 to 1. calls isn't exported, but getCalls closes over it — module scope gives you privacy for free.
  5. The require('node:path') inside the if only runs when you pass --report. Because require is just a function, it can sit in a branch, a loop or a function body, and take a computed string. That flexibility is also the downside: tools can't know a file's dependencies without running it.
AdvancedPitfall: rebinding exports

#ES modules in Node

ES modules are the language's own module system: import/export declarations that the engine reads before it runs anything. The syntax — named vs default exports, aliases, namespaces — is covered on the JavaScript site under ES modules: named and default exports. What's specific to Node is how it decides that a file is an ES module, and what you lose and gain when it is.

Node decides per file, by looking at the file name first and the nearest package.json second:

FileTreated as
x.mjsES module, always
x.cjsCommonJS, always
x.js, nearest package.json has "type": "module"ES module
x.js, nearest package.json has "type": "commonjs"CommonJS
x.js, no "type" field (or no package.json)CommonJS, unless the file contains ESM syntax — then Node re-parses it as ESM

That last row is syntax detection. It's convenient for quick scripts, but Node warns about it when there is a package.json that simply forgot the field:

Output
$ node main.js     # package.json is {"name":"typeless"}, main.js uses import
(node:64655) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///…/typeless/main.js is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to /…/typeless/package.json.
hi Rohit
AdvancedDeclaring type, and what changes in ESM

The fix is what the warning says: declare "type" explicitly in every package you own (see package.json for production services). With no package.json at all, the same file runs silently. If you declare the type and then use the other system's syntax, you get a hard error instead: SyntaxError: Cannot use import statement outside a module for import under "type": "commonjs", and ReferenceError: require is not defined in ES module scope, you can use import instead for require under "type": "module".

Here's what changes inside an ES module:

paths.mjs
import { createRequire } from 'node:module';
import config from './config.json' with { type: 'json' };

console.log(typeof module, typeof exports, typeof __dirname, this);
console.log(import.meta.url.split('/').slice(-2).join('/'));
console.log(import.meta.filename.split('/').pop(), import.meta.dirname.split('/').pop());

const require = createRequire(import.meta.url);
console.log(require('./legacy.cjs'), config.port);

const answer = await new Promise((r) => setTimeout(() => r(42), 10)); // top-level await
console.log('awaited', answer);
AdvancedOutput: ESM scope and import.meta
Output
undefined undefined undefined undefined
esm2/paths.mjs
paths.mjs esm2
{ legacy: true } 3000
awaited 42
What’s happening
  1. Before any line runs, Node parses paths.mjs, sees two import declarations, loads node:module and config.json, and links the names. Only then does the body execute.
  2. module, exports and __dirname are all undefined, and so is top-level this. There's no wrapper function here — those names simply don't exist in ES module scope. Using __dirname directly throws ReferenceError: __dirname is not defined in ES module scope.
  3. The replacement is import.meta: import.meta.url is this file's file:// URL, and import.meta.filename / import.meta.dirname give the plain paths that __filename / __dirname used to.
  4. createRequire(import.meta.url) builds a real CommonJS require that resolves relative to this file. It's the escape hatch for loading a .cjs file or a JSON file synchronously from ESM. (Declaring it as const require means using require on an earlier line throws Cannot access 'require' before initialization — the usual temporal dead zone.)
  5. JSON needs an import attribute: with { type: 'json' }. Leave it off and Node refuses with TypeError [ERR_IMPORT_ATTRIBUTE_MISSING]: Module "file:///…/config.json" needs an import attribute of "type: json". You also only get a default export — import { port } from './config.json' fails with The requested module './config.json' does not provide an export named 'port'.
  6. await at the top level is allowed. The module's evaluation pauses for 10 ms, and anything importing paths.mjs waits with it. That's a capability CommonJS can't have, because require must return synchronously.
AdvancedESM needs full file extensions

One more difference trips people up on day one: relative imports need the full file name. CommonJS tries extensions for you; ESM uses the specifier as a URL and doesn't guess:

Output
$ node noext.js     # contains: import { greet } from './util';
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/…/typed/util' imported from /…/typed/noext.js
Did you mean to import "./util.js"?
AdvancedNo directory imports in ESM

The same goes for directories: import('./lib') fails with ERR_UNSUPPORTED_DIR_IMPORT, where require('./lib') would have found lib/index.js. There's more on this in Module resolution.

AdvancedRecommendation: ESM vs CommonJS for new code

#CommonJS vs ES modules

The two systems differ in when they do their work, and almost every other difference follows from that. CommonJS does everything at runtime, one require at a time. ESM splits loading into phases: find and parse every file in the graph, link every import to its export, and only then evaluate the code, dependencies first.

CommonJSES modules
Syntaxrequire() / module.exportsimport / export
When dependencies loadWhen a require call runsBefore the module body runs (static imports)
LoadingSynchronousAsynchronous phases; await allowed at top level
Dependencies known up front?No — require can take any computed stringYes — static imports are declarations
What the importer getsThe module.exports object (destructuring copies values)Live, read-only bindings to the exporter's variables
Relative specifiersExtension and index.js optionalFull file name required
__dirname, __filenameWrapper parametersimport.meta.dirname, import.meta.filename
JSONrequire('./x.json')import x from './x.json' with { type: 'json' }
Top-level thismodule.exportsundefined
Strict modeOpt-in ('use strict')Always
Cacherequire.cache (public, deletable)Internal module map, keyed by URL

The JavaScript site goes through two of these rows in depth: live bindings and the evaluation order (why an ES module's imported files run before its own first line). The order difference is the one interviewers like to test:

Same graph, two module systems

main loads a and then b; both of them load c, which logs once and exports { hits: 0 }. Each importer increments hits. What do the two versions print?

main.cjs
console.log('main starts');
require('./a.cjs');   // a.cjs: logs 'a starts', require('./c.cjs').hits++, logs 'a ends'
require('./b.cjs');   // b.cjs: logs 'b starts', increments c.hits, logs 'b sees hits =', c.hits
console.log('main ends');
main.mjs
console.log('main starts');
import './a.mjs';     // a.mjs: imports { counter } from c.mjs, logs 'a runs', counter.hits++
import './b.mjs';     // b.mjs: imports { counter }, logs 'b runs, hits =', ++counter.hits
console.log('main ends');
Show answer
Output
$ node main.cjs
main starts
a starts
c runs
a ends
b starts
b sees hits = 2
main ends

$ node main.mjs
c runs
a runs
b runs, hits = 2
main starts
main ends
What’s happening
  1. CommonJS follows source order. main starts prints first; the first require runs a.cjs, whose own require runs c.cjs in the middle of a — so c runs lands between a starts and a ends.
  2. When b.cjs requires c.cjs, the cache already has it, so c runs does not print again. b gets the same object a incremented, so it sees hits = 2.
  3. ESM hoists imports. Before main.mjs's first line runs, Node has loaded and linked a.mjs, b.mjs and c.mjs. Evaluation goes depth-first in import order: c.mjs (the first dependency of a), then a.mjs, then b.mjs (whose c is already evaluated), then main.mjs.
  4. So main starts prints after every dependency has run, even though it's the first line of the file. Both versions agree on the shared-state part: one c, evaluated once, hits ends at 2.

Interop: mixing the two

Real Node projects mix both systems, so the interop rules matter.

ESM importing CommonJS always works. The default import is the whole module.exports. Node also tries to offer named imports by statically scanning the CommonJS source for patterns like exports.add = … — it doesn't run the file to find them:

use-cjs.mjs
import math, { add, PI } from './math.cjs';   // math.cjs: exports.add = …; exports.PI = 3.14159;
import dyn from './dynamic.cjs';             // dynamic.cjs: module.exports = { [name]: … }
import * as dynNs from './dynamic.cjs';

console.log(math);                   // default = module.exports
console.log(add(2, 3), PI);          // named exports detected statically
console.log(dyn.add(2, 3));          // works through the default
console.log(Object.keys(dynNs));     // but no named "add" was detected
AdvancedOutput: importing CommonJS from ESM
Output
{ add: [Function (anonymous)], PI: 3.14159 }
5 3.14159
5
[ 'default', 'module.exports' ]
What’s happening
  1. math.cjs assigns exports.add and exports.PI in plain sight, so Node's scanner (cjs-module-lexer) lists add and PI as named exports. Both the default import and the named imports work.
  2. dynamic.cjs builds its export with a computed key, { [name]: … }. The scanner can't evaluate name, so it detects no named exports at all.
  3. The default import still works — dyn is the real module.exports object, so dyn.add(2, 3) is 5.
  4. The namespace only contains default and module.exports (Node 26 exposes the raw module.exports under that string name too). import { add } from './dynamic.cjs' would fail at link time with SyntaxError: Named export 'add' not found. The requested module './dynamic.cjs' is a CommonJS module, which may not support all module.exports as named exports. — followed by a suggestion to use the default import.
  5. Rule of thumb: for CommonJS packages, the default import is the reliable one; named imports are a best-effort convenience. And they're snapshots taken when the CommonJS module finishes, not live bindings.
AdvancedCommonJS loading ESM: require and import()

CommonJS loading ESM has two routes. await import('./x.mjs') always works but returns a promise, so it has to live in an async function. And on current Node, plain require() of an ES module works too, as long as the ESM graph has no top-level await:

app.cjs
const ns = require('./greet.mjs');   // greet.mjs: export default function greet…; export const version = 2;
console.log(ns);
console.log(ns.default('Rohit'), ns.version);
console.log(process.features.require_module);

try {
  require('./tla.mjs');              // tla.mjs: const config = await …; export default config;
} catch (err) {
  console.log(err.code);
  console.log(err.message);
}

(async () => {
  const { default: config } = await import('./tla.mjs');
  console.log('import() works:', config);
})();
AdvancedOutput: require(esm) and top-level await
Output
[Module: null prototype] {
  __esModule: true,
  default: [Function: greet],
  version: 2
}
hi Rohit 2
true
ERR_REQUIRE_ASYNC_MODULE
require() cannot be used on an ESM graph with top-level await. Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla.
Required module: /…/interop/tla.mjs
Require stack:
- /…/interop/app.cjs
import() works: { port: 3000 }
What’s happening
  1. require('./greet.mjs') loads, links and evaluates the ES module synchronously and returns its namespace object, not the default export. That's why the call is ns.default('Rohit').
  2. Node adds __esModule: true to the namespace it hands to require when the module has a default export. That's the flag Babel and TypeScript's CommonJS output check, so transpiled code that does require(esm).default keeps working.
  3. process.features.require_module is true on this Node, which is how a library can check for the feature at runtime. It was enabled by default in Node 22.12 and backported to 20.19; on older versions require of a .mjs file throws ERR_REQUIRE_ESM.
  4. tla.mjs uses top-level await. require must return synchronously, so it can't wait for that promise — Node throws ERR_REQUIRE_ASYNC_MODULE instead of returning a half-evaluated module.
  5. await import('./tla.mjs') has no such limit: it returns a promise that resolves after the module (including its await) has finished, so config is { port: 3000 }.
AdvancedShaping require(esm) with 'module.exports'

If you're writing an ES module that CommonJS users will require, you can control what they get by exporting a binding under the string name 'module.exports':

logger.mjs
export default function log(msg) { console.log('[log]', msg); }
export { log as 'module.exports' };
Advanceduse-logger.cjs: requiring the custom export
use-logger.cjs
const log = require('./logger.mjs');
console.log(typeof log); // function — not a namespace object
log('direct');           // [log] direct
What’s happening
  1. export { log as 'module.exports' } uses an arbitrary string as an export name, which ES2022 allows.
  2. When CommonJS requires this module, Node checks for that export and returns its value instead of the namespace. So log is the function itself, and require('./logger.mjs')('hi') works like it did when the package was CommonJS.
  3. ESM importers are unaffected: import log from './logger.mjs' still uses the default export. It's a migration tool for packages moving to ESM without breaking const fn = require('pkg') callers.

#Lazy loading with dynamic import()

Static imports load everything up front: every module in the graph is parsed and evaluated before your entry file's first line runs. That's usually what you want, but some modules are expensive (a PDF renderer, a big SDK, a machine-learning model) and only a few requests need them. Lazy loading defers that cost until the first time a code path actually needs the module.

import() is the tool. It looks like a function call but it's special syntax: it takes any expression as the specifier, can appear anywhere (inside an if, a function, a loop), works in both ESM and CommonJS, and returns a promise for the module's namespace object. Think of it as "require, but asynchronous and for any module type".

report.mjs
console.log('  report.mjs: evaluating (expensive setup)');
const start = Date.now();
while (Date.now() - start < 200) {} // stand-in for loading a big library
export function generate(id) {
  return `report #${id}`;
}
Advancedserver.mjs: importing a report on demand
server.mjs
console.log('startup done at', Math.round(performance.now()), 'ms');

async function handleRequest(id, wantsReport) {
  if (!wantsReport) return `plain #${id}`;
  const { generate } = await import('./report.mjs'); // loaded on first use only
  return generate(id);
}

console.log(await handleRequest(1, false));
const t0 = performance.now();
const results = await Promise.all([handleRequest(2, true), handleRequest(3, true)]);
console.log(results, 'took', Math.round(performance.now() - t0), 'ms');
const t1 = performance.now();
console.log(await handleRequest(4, true), 'took', Math.round(performance.now() - t1), 'ms');
AdvancedOutput: the module loads on first use
Output
startup done at 10 ms
plain #1
  report.mjs: evaluating (expensive setup)
[ 'report #2', 'report #3' ] took 200 ms
report #4 took 0 ms
What’s happening
  1. server.mjs has no static import of report.mjs, so startup takes about 10 ms. With import { generate } from './report.mjs' at the top, the 200 ms setup would run before the first line — every process start would pay for it.
  2. Request 1 doesn't want a report, so the import() line never runs and report.mjs is never loaded.
  3. Requests 2 and 3 arrive together. Both reach import('./report.mjs'). The first call starts loading; the second finds the same module record already in progress and gets a promise for the same result. That's why evaluating prints once, and both requests wait the same ~200 ms.
  4. The resolved value is the namespace object, so destructuring { generate } pulls out the named export. For a default export you'd use const { default: fn } = await import(…) or (await import(…)).default.
  5. Request 4 takes 0 ms: the module is cached, so import() resolves on the next microtask with the existing namespace. Lazy loading costs you once, on first use, then behaves like a normal import.
AdvancedCommonJS: require inside a branch

In CommonJS the old way is a require inside the branch. It defers the cost the same way, but synchronously:

handler.cjs
function handleRequest(needsHeavy) {
  if (!needsHeavy) return 'No need to load the heavy module.';
  const heavy = require('./heavy.cjs'); // synchronous: blocks until heavy.cjs has run
  return heavy.doSomething();
}

for (const flag of [false, true, true]) {
  const t = performance.now();
  const out = handleRequest(flag);
  console.log(out, '-', Math.round(performance.now() - t), 'ms');
}
AdvancedOutput: synchronous lazy require
Output
No need to load the heavy module. - 0 ms
  heavy.cjs: evaluating
heavy work done - 200 ms
heavy work done - 0 ms
What’s happening
  1. The first call takes the early return, so heavy.cjs (which busy-waits 200 ms, like report.mjs) isn't touched.
  2. The second call reaches require('./heavy.cjs'). Node reads, wraps and runs the file right there, and handleRequest doesn't continue until it's done — 200 ms.
  3. The third call hits require.cache and returns instantly.
  4. The difference from import() is the shape of the call: require returns the module directly, so handleRequest stays a synchronous function, while import() gives you a promise and forces async all the way up. Neither one makes the module's top-level code cheaper — as the next gotcha says.
AdvancedTrade-offs: blocking setup and late errors
AdvancedGood uses for lazy loading

Where lazy loading earns its keep: CLIs with many subcommands (load only the one being run), rarely used admin or export routes, optional dependencies (try { await import('sharp') } catch { /* fall back */ }), and serverless functions where cold-start time matters. For a long-running server where every route is hot, eager imports are simpler and surface errors at boot.

#Module resolution

Resolution is the step before loading: turning the string you wrote ('express', './lib', 'node:fs') into one absolute file. It decides which file runs, and because the module cache is keyed by the resolved path, it also decides whether two require calls share an instance.

Node sorts specifiers into three kinds and handles each differently:

  • Built-in modules — fs, node:fs, node:test. Checked first; the node: prefix makes it explicit and can't be shadowed by an npm package.
  • Relative or absolute paths — anything starting with ./, ../ or /. Resolved against the importing file's folder. CommonJS tries the exact name, then .js, .json, .node, then the path as a directory (its package.json "main", then index.js). ESM tries only the exact URL.
  • Bare specifiers — express, lodash/merge. Looked up in node_modules, starting next to the importing file and walking up one directory at a time to the filesystem root. Inside the package, the package.json "exports" field (if present) decides which file you get — and which files you're allowed to reach at all.

Here's a small project with a fake npm package that ships both a CommonJS and an ES build:

node_modules/shout/package.json
{
  "name": "shout",
  "version": "1.0.0",
  "main": "./dist/legacy.cjs",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./package.json": "./package.json"
  }
}
Advancedsrc/main.cjs: resolving each specifier kind
src/main.cjs
const short = (p) => p.replace(process.cwd(), '.');

console.log(require.resolve('fs'), require.resolve('node:fs'));
console.log(short(require.resolve('./lib')));         // directory -> index.js
console.log(short(require.resolve('./settings')));    // tries .js, then .json
console.log(short(require.resolve('shout')));         // "exports" -> "require" condition
console.log(require('shout')('hello'));

try {
  require('shout/internal/helper.js');
} catch (err) {
  console.log(err.code);
}
console.log(module.paths.map(short).slice(0, 2));
AdvancedOutput: where each specifier resolved
Output
$ node src/main.cjs
fs node:fs
./src/lib/index.js
./src/settings.json
./node_modules/shout/dist/index.cjs
HELLO! (cjs build)
ERR_PACKAGE_PATH_NOT_EXPORTED
[ './src/node_modules', './node_modules' ]
What’s happening
  1. require.resolve runs the resolution algorithm without loading anything — the best debugging tool when "the wrong file" is loading. Built-ins resolve to their own names: fs and node:fs.
  2. ./lib has no file called lib, lib.js, lib.json or lib.node, so CommonJS treats it as a directory and finds src/lib/index.js. ./settings finds no settings.js and lands on settings.json (which require would parse for you).
  3. shout is bare, so Node walks module.paths — src/node_modules (doesn't exist), then ./node_modules (found). The package has an "exports" field, so "main" is ignored (legacy.cjs is never used), and because this is a require, the "require" condition picks dist/index.cjs.
  4. shout/internal/helper.js exists on disk, but "exports" doesn't list it, so Node refuses: ERR_PACKAGE_PATH_NOT_EXPORTED — "Package subpath './internal/helper.js' is not defined by "exports" in …/shout/package.json". That's the point of "exports": a package's public API is exactly what it lists, and deep imports into its internals stop working.
  5. module.paths is the list of node_modules folders this file will search, nearest first, continuing up to /node_modules. That walk is how a nested package can depend on a different version than your app does.
AdvancedThe same package resolved from ESM

The same package from ESM:

src/main.mjs
import shout from 'shout';
console.log(shout('hello'));                                          // HELLO! (esm build)
console.log(import.meta.resolve('shout').split('/node_modules/')[1]); // shout/dist/index.mjs
try {
  await import('./lib');
} catch (err) {
  console.log(err.code);                                              // ERR_UNSUPPORTED_DIR_IMPORT
}
What’s happening
  1. import uses the "import" condition, so the same bare name shout resolves to dist/index.mjs — a different file from the one require got.
  2. import.meta.resolve is ESM's require.resolve: it returns the resolved file:// URL synchronously, without loading the module.
  3. import('./lib') fails with ERR_UNSUPPORTED_DIR_IMPORT. ESM doesn't do directory or extension lookups for relative paths; you'd write ./lib/index.js.
  4. The two builds being different files has a consequence: if one part of an app requires shout and another imports it, the process loads two copies with separate state — the dual-package hazard (see Modules are singletons on the JavaScript site).
AdvancedEdge case: shadowing core modules
AdvancedCorrecting my notes: bare specifier lookup

#Module caching

The first require of a file runs it; every later require of the same resolved path returns the cached module.exports without running anything. My notes filed this under "caching for performance optimisation", and performance is one reason — but this is module caching, not data caching. It caches the result of running a file, not the results of the functions that file exports.

Mental model: require.cache is a plain object, { [absolutePath]: Module }. require resolves your specifier to a path, looks that path up, and either returns the stored module.exports or creates the entry, runs the file and stores it.

file1.cjs
console.log('file1 is being loaded');
module.exports = { message: 'Hello from file1', loadedAt: Date.now() };
Advancedmain.cjs: requiring a file twice
main.cjs
const file1 = require('./file1.cjs');        // runs file1.cjs
const file1Again = require('./file1.cjs');   // cache hit: file1.cjs does not run again
const viaFullPath = require(__dirname + '/file1.cjs');

console.log(file1 === file1Again, file1 === viaFullPath);

const key = require.resolve('./file1.cjs');
console.log(Object.keys(require.cache).map((k) => k.split('/').pop()));
console.log(require.cache[key].exports === file1, require.cache[key].loaded);
AdvancedOutput: the file runs only once
Output
file1 is being loaded
true true
[ 'main.cjs', 'file1.cjs' ]
true true
What’s happening
  1. The first require('./file1.cjs') resolves to /…/cache/file1.cjs, misses the cache, and runs the file: file1 is being loaded prints and module.exports becomes the object with loadedAt set to the current time.
  2. The second call resolves to the same path, hits the cache, and returns the same object — no log, and loadedAt doesn't change. file1 === file1Again is true because there's only one object.
  3. require(__dirname + '/file1.cjs') is a different string but the same resolved path, so it's the same cache entry: file1 === viaFullPath is true as well. The key is the resolved file, not what you typed.
  4. require.cache holds both main.cjs (the entry point is cached too) and file1.cjs. Each entry is a Module object; its exports is what require returns, and loaded is true now that the file has finished.
AdvancedWhy modules are cached

Why it's designed this way:

  • Cost — reading, compiling and running a file once, instead of on every require from every file that needs it.
  • Consistency — every part of the app that needs ./db gets the same object, so a connection pool or a config object is created once. That's the basis of Shared state and the singleton pattern.
  • Cycles — Node puts the module in the cache before running it. That's what lets a circular require return at all, instead of recursing forever (see Circular dependencies).

ES modules have their own cache keyed by URL, not exposed as an object. Because it's a URL, a query string makes a different key: import('./counter.mjs?v=2') evaluates counter.mjs a second time and gives you a separate instance. That trick doesn't reach CommonJS files, though — import('./counter.cjs?v=2') still returned the already-loaded CommonJS module, because CommonJS modules are cached by file path.

AdvancedEdge case: case-insensitive paths
AdvancedNot a data cache: caching results

#Shared state and the singleton pattern

Module caching has a consequence that's easy to rely on and easy to be bitten by: every file that requires a module gets the same exported object, so a mutation made through one import is visible through all the others. My notes marked this as crucial, and it is — it's how Node apps share a database pool, a config object, a logger or an in-process cache without passing them around.

The mental model is a notice board in a shared office: there's only one board (the cached exports object). Anyone can pin something to it or take something down, and everyone who looks at it sees the current state. What you can't do is take a photocopy and expect it to update.

state.cjs
module.exports = { counter: 0 };
Advancedincrement.cjs: mutating shared state
increment.cjs
const state = require('./state.cjs');
state.counter += 1;
console.log('Incremented:', state.counter);
Advancedmain.cjs: object vs destructured copy
main.cjs
const state = require('./state.cjs');
const { counter } = require('./state.cjs'); // copies the number 0 into a local variable
console.log('Initial counter:', state.counter);

require('./increment.cjs');
console.log('After increment:', state.counter);
console.log('Destructured copy:', counter);
AdvancedOutput: shared object, stale copy
Output
Initial counter: 0
Incremented: 1
After increment: 1
Destructured copy: 0
What’s happening
  1. main.cjs requires state.cjs for the first time: it runs, creating one object { counter: 0 }, which is cached. state in main.cjs refers to that object.
  2. The destructuring const { counter } = require(...) hits the cache and gets the same object, but then copies the property's current value, 0, into a new local variable. That variable has no connection to the object afterwards.
  3. require('./increment.cjs') runs increment.cjs, whose require('./state.cjs') also hits the cache — so its state is the very same object. state.counter += 1 changes it from 0 to 1 and logs Incremented: 1.
  4. Back in main.cjs, state.counter reads the property on the shared object, so it's 1. The destructured counter is still 0 — the photocopy.
  5. This is the CommonJS version of the live-binding difference: share objects (and read properties at use time) if you want updates to be seen. ESM export let bindings are live even for primitives — see live bindings.
AdvancedSingleton resource: caching the pool promise

The practical pattern is a module that owns one expensive resource. Note that it caches the promise, not the finished connection, so concurrent first callers don't each open their own:

db.cjs
let connections = 0;
let poolPromise = null;

async function connect() {
  connections++;
  await new Promise((r) => setTimeout(r, 50)); // pretend to open sockets
  return { query: async (sql) => `rows for ${sql}`, id: connections };
}

function getPool() {
  poolPromise ??= connect();  // first caller starts it; everyone else shares the same promise
  return poolPromise;
}

module.exports = { getPool, connectionCount: () => connections };
Advancedusers.cjs: a consumer of the pool
users.cjs
const { getPool } = require('./db.cjs');
exports.list = async () => (await getPool()).query('SELECT * FROM users');
Advancedapp.cjs: one pool for every module
app.cjs
const users = require('./users.cjs');
const orders = require('./orders.cjs'); // same as users.cjs, but SELECT * FROM orders
const db = require('./db.cjs');

Promise.all([users.list(), orders.list(), users.list()]).then(async (rows) => {
  console.log(rows);
  console.log('connections opened:', db.connectionCount());
  console.log('pool id:', (await db.getPool()).id);
});
AdvancedOutput: one pool shared by both modules
Output
[
  'rows for SELECT * FROM users',
  'rows for SELECT * FROM orders',
  'rows for SELECT * FROM users'
]
connections opened: 1
pool id: 1
What’s happening
  1. users.cjs, orders.cjs and app.cjs all require db.cjs, but it runs only once, so there is one poolPromise variable and one connections counter for the whole process.
  2. Promise.all starts three queries in the same tick. The first getPool() sees poolPromise is null, calls connect() (counter 0 → 1) and stores the pending promise.
  3. The second and third calls happen while that promise is still pending (it takes 50 ms). ??= sees a non-null value and doesn't call connect() again — they get the same pending promise.
  4. When it resolves, all three awaits continue with the same pool object. connections opened: 1 proves only one connection was made; pool id: 1 confirms later callers still get it.
  5. If you cached the result instead (if (!pool) pool = await connect()), all three callers would see pool empty during those 50 ms and open three connections. Caching the promise is the same trick used for cache stampedes.
AdvancedState isn't shared across threads

Module-level state is per module instance, per thread. It's not shared between worker threads, cluster workers, or Kubernetes pods. Each has its own heap and its own module cache:

worker-demo.cjs
const { Worker, isMainThread } = require('node:worker_threads');
const state = require('./state.cjs');

if (isMainThread) {
  state.counter = 100;
  console.log('main thread sets counter to', state.counter);
  new Worker(__filename).on('exit', () => {
    console.log('main thread still sees', state.counter);
  });
} else {
  console.log('worker sees counter =', state.counter); // its own fresh copy of state.cjs
  state.counter = -1;
}
AdvancedOutput: the worker gets its own copy
Output
main thread sets counter to 100
worker sees counter = 0
main thread still sees 100
What’s happening
  1. The main thread loads state.cjs and sets counter to 100 on its copy.
  2. new Worker(__filename) starts a new JavaScript environment on another thread, running the same file. It has an empty module cache, so its require('./state.cjs') runs state.cjs again and gets a brand-new { counter: 0 }.
  3. The worker logs 0 and sets its own copy to -1. That write never reaches the main thread.
  4. When the worker exits, the main thread still sees 100. Same file, two instances, no sharing. Cluster workers are separate processes, and pods are separate machines, so it's even more true there.
AdvancedPitfalls: singletons across pods and tests

#Clearing require.cache

Because require.cache is a plain object, you can delete an entry, and the next require of that path will run the file again. That's how hot-reloading dev tools and some test helpers work. The catch is that deleting a cache entry doesn't change anything that already exists — it only affects future require calls. You end up with two instances of the module alive at once.

Think of it as reprinting the office notice board: new visitors see the fresh board, but everyone who already took the old one home is still looking at it.

counter.cjs
console.log('  counter.cjs evaluated');
module.exports = { count: 0 };
Advancedapp.cjs: deleting a cache entry
app.cjs
const key = require.resolve('./counter.cjs');

const first = require('./counter.cjs');
first.count = 5;

delete require.cache[key];               // forget the module...
const second = require('./counter.cjs'); // ...so this runs counter.cjs again

console.log(first === second, first.count, second.count);
console.log(require.cache[key].exports === second);
AdvancedOutput: a fresh instance after deletion
Output
  counter.cjs evaluated
  counter.cjs evaluated
false 5 0
true
What’s happening
  1. require.resolve gives the exact cache key: the absolute path of counter.cjs. Deleting with the specifier string './counter.cjs' would do nothing, because that isn't a key.
  2. The first require runs the file (first log) and caches { count: 0 }. first.count = 5 mutates that object.
  3. delete require.cache[key] removes the entry. The old object isn't destroyed — first still points to it.
  4. The second require misses the cache, runs counter.cjs again (second log) and caches a new { count: 0 }. So first === second is false, and the values are 5 and 0.
  5. This is split state: any module that grabbed counter.cjs before the delete keeps the old object, and anything that requires it afterwards gets the new one. Two parts of the app now disagree about the count.
AdvancedReloading a module that owns a timer

It gets worse when the module owns resources, because the old instance's timers, sockets and listeners keep running:

ticker.cjs
globalThis.loads = (globalThis.loads ?? 0) + 1;
const version = globalThis.loads;
const timer = setInterval(() => console.log(`tick from load #${version}`), 100);
module.exports = { version, stop: () => clearInterval(timer) };
Advancedreload.cjs: reloading the ticker
reload.cjs
const key = require.resolve('./ticker.cjs');
const v1 = require('./ticker.cjs');

setTimeout(() => {
  delete require.cache[key];
  const v2 = require('./ticker.cjs');   // "hot reload"
  console.log('reloaded:', v1.version, '->', v2.version);
  setTimeout(() => {
    v1.stop();
    v2.stop();
  }, 220);
}, 150);
AdvancedOutput: the old timer keeps ticking
Output
tick from load #1
reloaded: 1 -> 2
tick from load #1
tick from load #2
tick from load #1
tick from load #2
What’s happening
  1. ticker.cjs counts its loads in a global (globals survive cache deletion — they aren't module state) and starts an interval. Load #1's interval fires at about 100 ms.
  2. At 150 ms the cache entry is deleted and ticker.cjs runs again as load #2, starting a second interval. Nothing stopped the first one — the cache never knew about it.
  3. From then on both instances tick: #1 at 200 and 300 ms, #2 at 250 and 350 ms. The output was identical across six runs because the timings are 50 ms apart.
  4. The demo only ends because reload.cjs kept both stop functions. A real hot-reloader that just deletes cache entries leaks a timer (or a DB pool, or a socket) on every reload unless each module exposes a dispose hook and the reloader calls it.
AdvancedFactories instead of cache deletion

#Circular dependencies

A circular dependency is when module A requires B and B (directly or through others) requires A. Node doesn't refuse or loop forever. Because a module is put in the cache before its code runs, the inner require of A finds A already in the cache — still half-finished — and returns whatever A has exported so far. The bug isn't a crash at the require; it's code that reads exports that don't exist yet.

Mental model: a colleague asks for your report while you're still writing it. Node doesn't make them wait — it hands over the pages you've written so far. If you've only written the title, that's all they get.

Here's the example from my notes:

a.cjs
const b = require('./b.cjs');
console.log('In a, b:', b);
module.exports = 'Hello from a';
Advancedb.cjs: the other half of the cycle
b.cjs
const a = require('./a.cjs');
console.log('In b, a:', a);
module.exports = 'Hello from b';
Advancedmain.cjs: entering the cycle
main.cjs
require('./a.cjs');
AdvancedOutput: the partial exports b sees
Output (Node 26)
In b, a: {}
In a, b: Hello from b
(node:79230) Warning: Accessing non-existent property 'Symbol(nodejs.util.inspect.custom)' of module exports inside circular dependency
(Use `node --trace-warnings ...` to show where the warning was created)
(node:79230) Warning: Accessing non-existent property 'constructor' of module exports inside circular dependency
(node:79230) Warning: Accessing non-existent property 'Symbol(Symbol.toStringTag)' of module exports inside circular dependency
What’s happening
  1. main.cjs requires a.cjs. Node creates a's module object with module.exports = {}, caches it, and starts running a.cjs.
  2. a.cjs's first line requires b.cjs: not cached, so Node caches b (exports {}) and starts running it. a.cjs is paused on line 1.
  3. b.cjs requires a.cjs. It's in the cache — so Node returns a's current module.exports without running anything. a never got past line 1, so that's the original empty object: In b, a: {}.
  4. b.cjs finishes and sets its module.exports to 'Hello from b'. Control returns to a.cjs line 1 with b = 'Hello from b', which prints In a, b: Hello from b (no quotes — console.log prints string arguments as-is).
  5. a.cjs then sets module.exports = 'Hello from a'. That replaces the object in a's module record, but b's a variable still points to the old {} — and always will.
  6. The warnings come from Node 26, which hands out the half-finished exports wrapped in a proxy that warns when someone reads a property that doesn't exist. console.log in b.cjs inspected that object (looking up util.inspect.custom, constructor and Symbol.toStringTag), which triggered three warnings. They print last because warnings are emitted asynchronously. node --trace-warnings main.cjs shows they come from b.cjs line 2.
AdvancedCorrecting my notes: what b receives
AdvancedAdding to vs replacing exports in a cycle

What b sees depends on how far a got, and whether a adds to its exports or replaces them:

a.cjs
exports.name = 'A';
const b = require('./b.cjs');
exports.ready = true;
Advancedb.cjs: reading a during and after load
b.cjs
const a = require('./a.cjs');
console.log('during load:', a.name, a.ready);
setTimeout(() => console.log('later:', a.name, a.ready), 0);
AdvancedOutput: values during and after load
Output
$ node a.cjs
during load: A undefined
(node:64155) Warning: Accessing non-existent property 'ready' of module exports inside circular dependency
(Use `node --trace-warnings ...` to show where the warning was created)
later: A true
What’s happening
  1. a.cjs starts and puts name: 'A' on its exports object before requiring b.
  2. b.cjs requires a.cjs, gets the partial exports object { name: 'A' }, and logs A undefined. ready doesn't exist yet, so Node warns about reading it — the warning is genuinely useful here: it points at a real ordering bug.
  3. b finishes, a resumes and adds ready = true to the same object b is holding.
  4. When the timer fires, b reads a.ready again and sees true. Adding properties to exports keeps one object, so late readers catch up; replacing module.exports (the first example) leaves them stranded on the old object.
AdvancedStale reference: x is not a function

That stranded reference is the bug you actually meet in real code, usually as x is not a function:

a.cjs
const b = require('./b.cjs');
module.exports = { name: 'A', hello: () => 'hello from A' };
Advancedb.cjs: stale vs lazy lookups
b.cjs
const a = require('./a.cjs');           // gets a.cjs's ORIGINAL exports object: {}
exports.callA = () => a.hello();        // closes over that stale object
exports.callALazily = () => require('./a.cjs').hello(); // looks a.cjs up at call time
Advancedmain.cjs: calling both versions
main.cjs
require('./a.cjs');
const b = require('./b.cjs');
console.log(b.callALazily());
try {
  b.callA();
} catch (err) {
  console.log(err.message);
}
AdvancedOutput: lazy call works, stale call fails
Output
hello from A
a.hello is not a function
(node:73150) Warning: Accessing non-existent property 'hello' of module exports inside circular dependency
(Use `node --trace-warnings ...` to show where the warning was created)
What’s happening
  1. Loading follows the same path as before: a starts, requires b, b requires a and receives a's original empty exports object, which it stores in a.
  2. b defines two functions. callA closes over the variable a — the empty object. callALazily calls require('./a.cjs') when it runs.
  3. a finishes and replaces module.exports with { name, hello }. The cache entry now points to the new object; b's variable still points to the old one.
  4. callALazily() runs after everything has loaded, so its require returns the cached, finished exports and hello() works.
  5. callA() reads hello from the empty object, gets undefined, and calling it throws a.hello is not a function. Node still warns, because b is holding the proxy-wrapped abandoned object.
AdvancedBreaking cycles

The lazy require above is one fix, and it's the "use dynamic require calls only when needed" item in my notes. It fixes the timing, but the two modules still depend on each other. Usually the better fix is structural: a cycle means two modules share something, so move that something into a third module that both depend on.

validation.cjs
// The piece both modules needed, moved out so neither needs the other
exports.isValidUserId = (id) => Number.isInteger(id) && id > 0;
users.cjs
const { isValidUserId } = require('./validation.cjs');
const orders = require('./orders.cjs');
exports.summary = (id) => (isValidUserId(id) ? `user ${id}: ${orders.countFor(id)} orders` : 'bad id');
orders.cjs
const { isValidUserId } = require('./validation.cjs'); // used to be require('./users.cjs')
const placed = [{ userId: 1 }, { userId: 1 }, { userId: 2 }];
exports.countFor = (id) => (isValidUserId(id) ? placed.filter((o) => o.userId === id).length : 0);
What’s happening
  1. Before the refactor, orders.cjs required users.cjs just to validate a user ID, while users.cjs required orders.cjs to count orders — a cycle.
  2. isValidUserId moves to validation.cjs, which requires nothing. Now the graph is a tree: users → orders → validation and users → validation.
  3. require('./users.cjs').summary(1) returns 'user 1: 2 orders'. Every module is fully loaded before anyone reads its exports, so there's no half-finished state to observe.

Other options: pass the dependency in instead of requiring it (dependency injection — see Design patterns in Node), or emit an event instead of calling back into the other module. To find cycles in a codebase, madge --circular src/ (an npm tool) lists every cycle, and many teams fail CI on new ones; ESLint's import/no-cycle rule does the same at lint time.

AdvancedCycles in ES modules

ES modules handle cycles differently, and more loudly. Imports are live bindings to the exporter's variables, and all the bindings exist (linked) before any code runs. So a module in a cycle doesn't get a stale copy — it gets the real variable. But if that variable is a let, const or class that hasn't been initialised yet, reading it is a temporal dead zone error:

a.mjs
import { b } from './b.mjs';
console.log('a.mjs runs, b =', b);
export const a = 'A';
export function describeA() { return 'I am A'; }
b.mjs
import { a, describeA } from './a.mjs';
console.log('b.mjs runs');
console.log(describeA());          // function declaration: hoisted, already initialised
console.log(a);                    // const: still in its temporal dead zone
export const b = 'B';
Output
$ node a.mjs
b.mjs runs
I am A
file:///…/circ-esm/b.mjs:4
console.log(a);                    // const: still in its temporal dead zone
            ^

ReferenceError: Cannot access 'a' before initialization
    at file:///…/circ-esm/b.mjs:4:13
What’s happening
  1. Node loads a.mjs, sees it imports b.mjs, loads b.mjs, sees it imports a.mjs (already in the graph — the cycle is noted, not followed). Linking connects b's a and describeA to a.mjs's variables, and a's b to b.mjs's.
  2. Evaluation goes dependencies first. a.mjs depends on b.mjs, so b.mjs runs first, even though you started with node a.mjs. It prints b.mjs runs.
  3. describeA() works: function declarations are hoisted and initialised when the module is instantiated, before any module code runs.
  4. console.log(a) reads a.mjs's const a, which exists (it's linked) but hasn't been initialised because a.mjs's body hasn't run. That's the TDZ, so it throws ReferenceError: Cannot access 'a' before initialization and the process exits with code 1.
  5. Compared with CommonJS: instead of silently getting {} or undefined and failing later, ESM fails immediately, on the exact line. And reading the binding later works — if b.mjs exported const readA = () => a and a.mjs called it after its own export const a = 'A', it would return 'A', because live bindings always show the current value.
What does b log?
a.cjs
exports.loaded = false;
const b = require('./b.cjs');
exports.loaded = true;
module.exports.extra = 'x';
b.cjs
const a = require('./a.cjs');
console.log(a.loaded, a.extra);
setTimeout(() => console.log(a.loaded, a.extra), 0);

Run with node a.cjs.

Show answer

false undefined, then (after a Node warning about extra) true x.

What’s happening
  1. a.cjs puts loaded: false on its original exports object, then requires b.
  2. b gets that partial object, so a.loaded is false and a.extra doesn't exist yet → false undefined. Reading the missing extra triggers Node's "Accessing non-existent property 'extra' of module exports inside circular dependency" warning.
  3. a resumes. exports.loaded = true and module.exports.extra = 'x' both write to the same object — exports and module.exports still point to it because nothing reassigned them.
  4. When the timer fires, b reads the same object again: true x. If line 4 had been module.exports = { extra: 'x' } instead, b would still print true undefined, because it would be holding the abandoned object.

Built from Rohit’s “Nodejs interview study” and “Nodejs-Interview-Chatgpt” docs. Examples are run on Node 26.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.