Get E-Book
The Module System

Node.js require() Internals: Module._load & CJS Cache

Ishtmeet Singh @ishtms/February 22, 2026/28 min read
#nodejs#modules#require#CommonJS#module-cache

require() is the synchronous entry point into Node's CommonJS loader. A single call can resolve a specifier, check multiple caches, read source from disk, choose an extension handler, compile the module body, execute top-level code, and return the current module.exports value. The internal Module._load() function coordinates most of that work.

How require() Works

The cache is not just an optimization around that path. It is part of the CommonJS runtime contract. A second require() for the same resolved filename usually returns the same exports object, and circular dependencies expose partially initialized exports because the cache entry exists before the module body finishes. That ordering is the reason many CommonJS edge cases look strange at first but still follow a consistent rule.

This chapter follows the path from a JavaScript call to require() through the internal Module object, filename resolution, extension handlers, source compilation, cache behavior, and the circular dependency cases that still surprise experienced developers.

Does require() return the same object every time?

For the same resolved filename, CommonJS normally returns the same module.exports value. When that value is an object or function, later require() calls receive the same reference from require.cache[filename].exports. Mutations on that exported object can be observed by later callers because the cache stores the Module instance and its current exports property.

js
// state.js
module.exports = { count: 0 };

// app.js
const a = require('./state');
const b = require('./state');
a.count += 1;
console.log(a === b, b.count); // true 1

Both calls resolve ./state to the same filename, so the second call hits require.cache. a and b point at the same exported object. The mutation through a changes the object that b already references.

The specifier string counts only after resolution. The cache key is the resolved filename. Two callers can write the same specifier and still reach different files when their parent directories, package scopes, symlink handling, or --preserve-symlinks settings differ. Deliberate cache deletion creates another evaluation too. For ordinary file-backed CommonJS, same resolved filename means one cached Module and one exported reference.

The Local require() Function

When you type require('fs') or require('./myFile'), you are calling a function that Node injected into your module's scope through the module wrapper described later in this chapter. require is not a global. It is a local variable created for your module, and it points to a function that eventually enters the Module._load path.

At a high level, one call follows this shape:

js
require(id)
  -> Module.prototype.require(id)
  -> wrapModuleLoad(id, parent, false)
  -> Module._load(id, parent, false)
  -> Module._resolveFilename(id, parent)
  -> Module._cache[filename]?
  -> module.load(filename)
  -> module.exports

CommonJS require loader path with a cache-hit bypass.

Figure 1.1 — A first-time require() takes the full synchronous path from resolution through evaluation. A cache hit bypasses file reading and compilation and returns the cached exports value.

That pseudocode is close to the real path, but the names are private Node internals. Module._load, wrapModuleLoad, requireDepth, and compileFunctionForCJSLoader match the Node v24 loader source; they can move or change across releases. They are useful for understanding the implementation, not as public APIs.

The require function your module receives is built on Module.prototype.require, a thin wrapper around the internal loading path. Simplified from Node v24, it validates the argument, increments the require-depth counter, and calls wrapModuleLoad, which adds tracing and hooks around Module._load.

js
Module.prototype.require = function(id) {
  validateString(id, 'id');
  requireDepth++;
  try {
    return wrapModuleLoad(id, this, false);
  } finally {
    requireDepth--;
  }
};

The third argument, false, tells the loading path that this module is being loaded as a dependency rather than as the main entry point. The requireDepth counter tracks nested CommonJS loading, including lifecycle details such as temporary stat caching around synchronous work.

The try/finally block is part of that accounting. The counter decrements whether the load succeeds or throws. If a target file has a syntax error, for example, the caller still receives the error, but the loader does not leave requireDepth permanently inflated for the rest of the process.

Module._resolveFilename

Before anything can be loaded, Node has to decide which concrete module the request names. require('./utils') might mean ./utils.js, ./utils.json, ./utils.node, or an index file inside ./utils/. A bare specifier such as require('express') starts the node_modules lookup algorithm. That full resolution algorithm is its own topic in the next subchapter, but the loader path depends on the high-level behavior of Module._resolveFilename.

The method receives the request string and the parent module. It first checks whether the specifier names a built-in module. Node maintains an internal list of built-ins such as fs, path, http, and net. If the request matches one of them, _resolveFilename returns the accepted request string and the loading path can skip probing application files on disk.

js
// Simplified from Node v24.
if (BuiltinModule.normalizeRequirableId(request)) {
  return request;
}

That early return avoids filesystem work for common calls such as require('fs') and require('path'). Modern code can also use the explicit node: prefix, as in require('node:fs'). The prefix forces the built-in path, bypasses require.cache spoofing, and has no filesystem fallback. Without the prefix, _resolveFilename still recognizes names such as 'fs' as built-ins, but the CommonJS loader checks Module._cache['fs'] before loading the real built-in module. Defensive code should prefer node: for built-ins when it wants to avoid cache shadowing by userland code.

For non-built-in specifiers, _resolveFilename asks Module._resolveLookupPaths for the directories that should be searched. Relative paths (./ and ../) resolve from the parent module's directory. Bare specifiers build a chain of node_modules directories by walking upward from the parent module's location to the filesystem root.

With that search list in hand, Module._findPath tries the possible file extensions (.js, .json, .node) and the directory index fallbacks. The first matching file wins. _findPath also maintains Module._pathCache, an internal cache that maps a (request, paths) tuple to a resolved filename so repeated resolution can avoid repeated filesystem stat calls.

js
const cacheKey = request + '\x00' + paths.join('\x00');
const entry = Module._pathCache[cacheKey];
if (entry) return entry;

The \x00 null byte separates tuple parts without ambiguity while still leaving the lookup as a single string-keyed property access. If no candidate matches after all paths, extensions, and directory indexes are exhausted, the loader throws MODULE_NOT_FOUND. The error includes a require stack showing which modules led to the failed request. When you need to inspect the candidate search directories for a bare specifier, require.resolve.paths(request) exposes the lookup list.

Module._load as Coordinator

After resolution, Module._load turns the request into either a cached Module instance or a newly evaluated export value. The first move is to call Module._resolveFilename, which turns something like ./utils into an absolute path such as /home/user/project/utils.js.

That resolved filename becomes the normal file-module cache key. Module._cache is a plain JavaScript object keyed by absolute filenames. If the cache already contains an entry, _load can usually return its exports immediately. There is no file I/O and no compilation, just a property lookup.

js
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
  updateChildren(parent, cachedModule, true);
  if (cachedModule.loaded) return cachedModule.exports;
}

The cachedModule.loaded check is where circular dependencies start to show through. A module can be present in the cache before it has finished evaluating. In that case, the loader may return a partially initialized exports object rather than pretending the cycle does not exist.

If the request names a built-in module and no earlier cache entry handled it, Node loads it through the built-in module path instead of reading from disk. Built-ins have their own cache, separate from ordinary file-module entries in Module._cache.

For an uncached file module, _load creates new Module(filename, parent). The constructor starts with properties such as id, exports, filename, loaded, and children. The initial exports value is an empty object, loaded starts as false, and children starts as an empty array. The deprecated module.parent accessor points at the first parent that loaded the module, and module.paths is populated when the module is loaded.

The new module is inserted into Module._cache before the file is read. This timing is what keeps a cycle from becoming infinite recursion. If A requires B and B requires A, the second request for A finds A's cache entry and receives A's current module.exports value. It may be incomplete because A has not finished evaluating, but it is a real object reference.

Only after that cache insertion does _load call module.load(filename). That call reads the file, chooses the extension handler, compiles the source when necessary, and runs the module body. If loading throws because of a syntax error, a runtime exception during evaluation, or a nested require() failure, Node removes the module from Module._cache before the error continues to the caller.

js
let threw = true;
try {
  module.load(filename);
  threw = false;
} finally {
  if (threw) delete Module._cache[filename];
}

The loader uses finally because it wants cleanup without swallowing the original error. A failed load should not leave a broken module permanently cached. The next require() attempt starts from scratch.

When module.load finishes successfully, module.loaded is set to true, and _load returns the current module.exports value.

Module.prototype.load

The load method on a Module instance is responsible for dispatching to the correct file handler. Node keeps those handlers in Module._extensions, and load chooses one based on the resolved filename.

js
Module.prototype.load = function(filename) {
  this.filename = filename;
  this.paths ??= Module._nodeModulePaths(
    path.dirname(filename)
  );
  const extension = findLongestRegisteredExtension(filename);
  Module._extensions[extension](this, filename);
  this.loaded = true;
};

The extensionless case, such as require('./config'), has already been handled during resolution. _findPath probes config, config.js, config.json, config.node, and directory fallbacks before load() runs. By this point, Node has a concrete filename. findLongestRegisteredExtension() chooses the most specific registered handler for that filename, defaulting to .js only when no more specific registered extension matches.

Once the handler returns, this.loaded flips to true. From the loader's point of view, the module is complete, and its exports are whatever module.exports points to after evaluation.

Module._extensions: The Handler Registry

Module._extensions has three default keys: .js, .json, and .node. Each one represents a different way to turn a file into a CommonJS export value.

The .js handler reads source synchronously and calls module._compile with the source code. In Node v24, source loading goes through helper functions, but conceptually this is still a file read followed by wrapping and V8 compilation.

The .json handler is simpler. It reads the file synchronously, runs JSON.parse, and assigns the parsed object directly to module.exports. There is no wrapper function, no JavaScript compilation step, and no top-level code execution.

js
Module._extensions['.json'] = function(module, filename) {
  const content = fs.readFileSync(filename, 'utf8');
  module.exports = JSONParse(stripBOM(content));
};

The stripBOM call removes a UTF-8 byte order mark if one is present. Some editors on Windows add BOMs to files, and the JSON parser should not see that marker as part of the document.

The .node handler goes in the other direction: it calls process.dlopen(), which loads a compiled native addon through the operating system's dynamic linker (dlopen on Unix, LoadLibrary on Windows). The addon's registration code, whether it uses N-API or an older native-addon path, sets module.exports to whatever the native code provides.

The same registry can be extended from userland. require.extensions['.txt'] = function(mod, filename) { ... } still works, though it is deprecated. The mechanism is the same as the built-in handlers: read the file, transform or interpret the content, and set module.exports.

JSON loading has one ordering detail that catches people off guard. When you require('./config.json'), Node parses the file once and caches the resulting object. Later calls return that same object even if the file on disk has changed. Because the cached value is mutable, one consumer can also change what every later consumer sees.

js
// config.json: { "port": 3000 }
const cfg = require('./config.json');
cfg.port = 9999;

const cfg2 = require('./config.json');
console.log(cfg2.port); // 9999 - same cached object

Both variables refer to the same object stored behind the module cache entry. Mutating one mutates the shared cached value, which is why mutating required JSON can turn configuration into process-wide mutable state.

Module._compile: Where Source Becomes Code

module._compile is where CommonJS moves from loader bookkeeping into V8 compilation. It receives the raw JavaScript source string and turns it into a callable function. The sequence begins with source cleanup. If a file starts with #!, Node handles that shebang line before the JavaScript engine parses the body, which is why CLI scripts can begin with #!/usr/bin/env node.

After that, Node gives the source CommonJS semantics by running it as though it were inside a function with five parameters. The public wrapper template looks like this:

js
[
  '(function(exports, require, module, __filename, __dirname) { ',
  '\n});'
]

In wrapper-string paths, your source code is placed between those strings. In the default modern CommonJS compile path, Node creates the equivalent wrapper internally. Either way, a file containing const x = 5; module.exports = x; has this effective shape:

js
(function(exports, require, module, __filename, __dirname) {
const x = 5; module.exports = x;
});

That wrapper is why exports, require, module, __filename, and __dirname exist inside every CommonJS module. They are function parameters supplied by Node when it calls the wrapper. They are not globals.

Node v24 compiles CommonJS source through an internal CJS compile path (compileFunctionForCJSLoader in the v24 source) that returns a function with those wrapper parameters. Older and monkey-patched paths may still involve wrapped source strings and vm.Script-style machinery. In either case, V8 parses the JavaScript, produces executable code, and gives Node a callable function. Compilation prepares the function; it does not run the module body yet.

This compilation layer is separate from Module._cache. Module._cache stores evaluated module objects inside the current process. Node's module compile cache, when enabled through NODE_COMPILE_CACHE or the node:module API, stores V8 compile artifacts so a later load can reduce parse and compile work. Without that opt-in cache, a cache miss still has to compile source in the current process.

Once compilation succeeds, Node calls the compiled wrapper with the five CommonJS arguments.

js
compiledWrapper.call(
  module.exports,
  module.exports,
  require,
  module,
  filename,
  dirname
);

The first argument to call becomes this, so top-level this inside a CommonJS module is module.exports. That makes this === module.exports true at the top level, although it is rarely a good API contract to depend on.

When the wrapper function returns, the export value is whatever module.exports points to. Reassigning module.exports = someFunction replaces the value returned by require(). Assigning exports.foo = bar mutates the original exports object, so require() returns that object with the new property attached.

module.exports vs exports: The Aliasing Trap

The aliasing trap starts when the wrapper function is called. exports and module.exports begin as two names for the same object.

js
console.log(exports === module.exports); // true

Because both names point at the same empty object, adding a property through either name is visible through the other.

js
exports.greet = () => 'hello';
console.log(module.exports.greet()); // 'hello'

The moment you reassign exports, though, the two bindings diverge.

js
exports = { greet: () => 'hello' };
console.log(module.exports); // {} - still the original

require() returns module.exports. Always. It never looks at the local exports variable. Reassigning exports only changes that local binding; it does not change what the module exports.

That is why code that exports a single value uses module.exports = class MyThing {} rather than exports = class MyThing {}. The first replaces the actual export value. The second only points the local alias at a new value while module.exports still points at the original empty object.

The rule follows from the aliasing: use exports.name = value when adding named exports, and use module.exports = value when replacing the whole export.

Even module.exports has one more timing detail. The cache stores the Module instance, not a frozen snapshot of the export value. Each later require() reads the module object's current module.exports property. An asynchronous reassignment after evaluation can affect later consumers, but it will not update earlier consumers that already hold the old value. In practice, assign the final module.exports value synchronously during module evaluation.

A common single-value export is a constructor or class:

js
module.exports = class Database {
  constructor(url) { this.url = url; }
  query(sql) { /* ... */ }
};

The caller can then write const Database = require('./database') and receive the class directly. If the file had used exports = class Database { ... }, the caller would receive the original empty object. The mistake is common because exports = ... looks symmetrical with module.exports = ..., but only module.exports is the value returned by the loader.

A less common but useful pattern is exporting a function that also carries properties.

js
function greet(name) { return `hello ${name}`; }
greet.version = '1.0.0';
module.exports = greet;

The consumer can call require('./greet')('world') and also read require('./greet').version. Functions are objects in JavaScript, so they can carry properties. Several popular packages use this shape; express itself is a function you call to create an app, and it also exposes .Router, .static, and other properties.

Module._cache and require.cache

Module._cache is a prototype-less object (Object.create(null)) keyed by absolute filenames. When require('./utils') resolves to /home/user/project/utils.js, that full path is the cache key.

require.cache is the same object exposed through each module's local require function, so you can inspect or manipulate it.

js
console.log(Object.keys(require.cache));
// ['/home/user/project/index.js', '/home/user/project/utils.js', ...]

Each cached value is a Module instance with properties such as id, filename, loaded, exports, parent, and children. Deleting an entry forces that module's source file to be read and evaluated again on the next require().

js
delete require.cache[require.resolve('./config')];
const freshConfig = require('./config');

The deletion only affects future loads through the cache. Any module that already required ./config still holds a reference to the old exports value. After the next require(), the process can contain two different versions of that module's exports: the old one held by prior consumers and the new one returned by fresh calls.

Development hot-reload code sometimes leans on that behavior, but it is fragile. Production systems usually need a different design because cache deletion does not update existing references or undo side effects from the first evaluation.

The backing object uses Object.create(null) for a small defensive reason. A normal {} object inherits from Object.prototype, where keys such as toString, constructor, and hasOwnProperty already exist. A file would have to resolve to a colliding cache key for this to count in practice, but a prototype-less object removes the edge case entirely.

The cache also connects to require.main. require.main points to the Module instance for the entry-point file, the file passed to node something.js. A CommonJS file can compare it with its own module object to decide whether it is being run directly or loaded as a dependency.

js
function startServer() {
  // start the CLI/server entry point
}

if (require.main === module) {
  startServer();
}
module.exports = { startServer };

When you run node server.js, the comparison is true and startServer() runs. When another file does require('./server'), require.main still points to the process entry point, so the comparison is false and only the exports are prepared. If the process entry point is an ES module, require.main is undefined; this guard is a CommonJS entry-point pattern.

Synchronous Loading

Every step of require() is synchronous. Source loading for file modules is synchronous. Compilation is synchronous. Execution of the module body is synchronous too, unless the module starts asynchronous work internally. require() does not wait for that later work.

That design affects both startup and request-time code. At the top level of an entry point, synchronous loading usually happens before the server is accepting work. Later in the process, however, a first-time require() inside a hot path blocks the event loop while the module loads.

js
app.get('/report', (req, res) => {
  const report = require('./heavy-report-generator');
  res.json(report.generate());
});

The first request that reaches this handler pays for reading, compiling, and evaluating heavy-report-generator.js. Later requests hit the cache and return quickly, but the first one still pays the full synchronous cost.

Stable dependencies are usually better loaded before latency-sensitive asynchronous paths. Lazy or conditional require() is valid, but first-hit loading inside a request handler should be deliberate, especially for modules that read large files, perform heavy top-level setup, or trigger many nested loads.

CommonJS stayed synchronous because module evaluation can have side effects, and callers historically depended on those side effects being complete when require() returned. If require() had been asynchronous, every caller would have needed a different control-flow contract. ES Modules use a separate parse, link, and evaluate pipeline, with asynchronous steps for top-level await and loader behavior. CommonJS came first, and synchronous loading was the workable choice in 2009.

The synchronous contract also allows runtime branches around loading.

js
let parser;
if (process.env.USE_FAST_PARSER) {
  parser = require('fast-parser');
} else {
  parser = require('slow-but-safe-parser');
}

Assuming both packages exist, only the matching branch loads. The other module is never read, compiled, or evaluated. Static ESM import declarations cannot be placed behind runtime branches; they participate in linking at the top level. ESM code uses dynamic import() when it needs conditional runtime loading. This conditional loading behavior is one of the reasons CommonJS remains common in libraries with optional dependencies.

Startup ordering depends on the same property. Libraries that register process-level event handlers, such as custom uncaughtException handlers or APM tools, rely on being required early in the entry point. Because require() completes before the next line runs, the handler is installed before later CommonJS code in that file executes.

Circular Dependencies

Circular dependencies are where the cache timing becomes visible. Module A requires module B, and module B requires module A. Instead of treating that as a fatal error, CommonJS exposes partially initialized exports.

The key is the cache insertion discussed earlier: Module._load caches a module before executing it. When A starts loading and reaches require('./b'), B starts loading. When B reaches require('./a'), A is already in the cache, but A has not finished evaluating. B receives whatever A's module.exports contains at that paused point.

CommonJS circular dependency path exposing a partially populated cached exports object.

Figure 1.2 — In a cycle, the cache contains the module object before evaluation finishes. The second leg of the cycle receives the same exports object while it may still be only partially populated.

js
// a.js
exports.fromA = 'hello from A';
const b = require('./b');
exports.afterB = 'set after B loaded';

// b.js
const a = require('./a');
console.log(a.fromA);  // 'hello from A'
console.log(a.afterB); // undefined

exports.fromA exists because A set it before requiring B. exports.afterB is still missing because that line has not executed yet. A's execution is paused while B loads.

After B finishes, control returns to A and exports.afterB gets set. B already holds a reference to A's exports object, so if B reads a.afterB later, it will find the property. The reference is live; the initial access happened too early.

The common failure mode is reassignment. If A does module.exports = new SomeClass() after B has already received the old module.exports, B keeps a reference to the original empty object. The reassignment points A's module at a new object; it does not mutate the object B already holds.

Most circular dependency bugs come from exactly that pattern: a module in a cycle replaces module.exports after another module has captured the initial object. The usual fix is to avoid reassignment in the cycle, using exports.thing = ..., or to restructure the dependency graph so the cycle disappears.

When the graph cannot be changed immediately, a lazy require can move the load to the point where the value is actually needed.

js
// a.js
exports.getB = function() {
  const b = require('./b');
  return b.value;
};

If getB is called after both modules finish loading, require('./b') hits the cache and returns B's complete exports. If it is called during module initialization, partial exports can still be observed. This pattern appears in Node's own internal codebase. Its cost is an extra function call and cache lookup on each invocation; after the first load, the lookup is normally cheap enough unless profiling shows it has a measurable cost.

Node exposes enough metadata to inspect part of the loaded module graph. The module.children array tracks modules first loaded through a given module. Following children recursively can reveal cycles, but it is not a complete static dependency graph. In practice, developers more often trace the require() chain from the observed undefined export back to the cycle.

require.resolve()

require.resolve() runs the same resolution algorithm as Module._resolveFilename, but it stops after finding the resolved path. It does not load, compile, or execute the module.

js
console.log(require.resolve('./utils'));
// '/home/user/project/utils.js'

console.log(require.resolve('express'));
// '/home/user/project/node_modules/express/index.js'

If the module cannot be located, require.resolve() throws MODULE_NOT_FOUND. It is not a boolean existence API, so callers use try/catch when they want to test for an optional dependency.

require.resolve.paths(request) shows the directories that would be searched for a request string. A relative path returns the calling module's directory. A built-in module such as fs returns null. A bare specifier returns the node_modules chain plus any applicable legacy global lookup paths. The exact output varies by file location, platform, and process configuration.

js
console.log(require.resolve.paths('./utils'));
// ['/home/user/project']

console.log(require.resolve.paths('fs'));
// null

console.log(require.resolve.paths('express'));
// ['/home/user/project/node_modules', ...]

That makes it a useful debugging tool. When a package is installed but a process cannot find it, require.resolve.paths() shows where that process is actually looking.

The second argument to require.resolve is useful in tools and plugin systems because it changes the starting points for lookup.

js
require.resolve('some-plugin', {
  paths: ['/custom/lookup/path']
});

For bare specifiers, Node treats each supplied path as a starting directory and builds the usual node_modules hierarchy from there, while still including legacy global folders. The package still has to exist somewhere under that lookup chain.

Resolution results use Module._pathCache, just like the internal loader. Resolving the same module twice with the same effective options can skip filesystem checks on the second call. If the underlying file is deleted between calls, the cached result can still point to the old path. Long-running tools such as dev servers and watch-mode processes sometimes clear Module._pathCache, but there is no public API for it; direct mutation is private-internal behavior.

Inside lib/internal/modules/cjs/loader.js

The source file behind this machinery is lib/internal/modules/cjs/loader.js in the Node.js repository. In Node v24, it is roughly two thousand lines of JavaScript. Module._load, Module._resolveFilename, Module._compile, Module._extensions, and the cache all live in that file.

The Module class starts with a standard constructor. Simplified from the Node v24 source, it has this shape:

js
function Module(id = '', parent) {
  this.id = id;
  this.path = path.dirname(id);
  this.exports = {};
  this.filename = null;
  this.loaded = false;
  this.children = [];
}

Every uncached file module gets one of these instances. The id is usually the resolved absolute filename, path is the containing directory, exports starts as an empty plain object, filename is filled in by load(), loaded flips to true only after the handler runs, and children accumulates modules this module loads.

The Module._nodeModulePaths method builds the node_modules search list for bare specifiers. It takes a directory and walks toward the root, appending node_modules at each level.

js
Module._nodeModulePaths = function(from) {
  from = path.resolve(from);
  const paths = [];
  for (/* each parent directory */) {
    paths.push(path.join(dir, 'node_modules'));
  }
  return paths;
};

For a Unix file at /home/user/project/src/utils.js, that produces paths such as /home/user/project/src/node_modules, /home/user/project/node_modules, /home/user/node_modules, /home/node_modules, and /node_modules. Windows follows the same idea with backslash separators and drive letters. These paths are stored in module.paths and used during bare-specifier resolution.

The _compile method is where the loader crosses into engine integration. In Node v24, the default CJS path uses an internal compile function for the CommonJS loader rather than literally calling the public vm.compileFunction API. For authors, the important shape is the same: Node compiles the module as a function that receives exports, require, module, __filename, and __dirname.

js
const result = compileFunctionForCJSLoader(
  content,
  filename,
  false, // is_sea_main
  shouldDetectModule
);
const compiledWrapper = result.function;

The wrapper strings (Module.wrapper[0] and Module.wrapper[1]) still exist for the public API and for patched wrapper cases, but the default path is internal. The compile call receives the source content and filename, performs CommonJS-specific handling such as module syntax detection, and returns the function Node will call.

After compilation, _compile builds the require function for that specific module. This local function has the correct Module.prototype.require.call(this, ...) binding and the right resolve, cache, and main properties. Then Node calls the compiled function with module.exports as this and passes the five wrapper parameters. At that point, user code runs top to bottom. Each nested require() repeats the same resolve, cache-check, load, compile, execute sequence and blocks until it returns.

Caching has one more V8-related layer. When the module compile cache is enabled, Node can generate and consume compile-cache data for modules. That is different from require.cache: require.cache prevents re-reading and re-executing a module inside one process, while the compile cache can reduce parse and compile work across process runs. It is a startup performance feature, not part of ordinary CommonJS value caching.

The loader also handles main-module bookkeeping. When the file passed to node is loaded, Module._load receives true for isMain. It sets process.mainModule (deprecated in favor of require.main) and sets module.id to '.' instead of the filename. That is the state behind require.main === module.

Directory loading is another branch in the same file. If you require a directory such as require('./myLib'), and the directory has a package.json with a main field, that field chooses the entry point. Without a usable package.json, Node tries index.js, then index.json, then index.node. This lookup is part of Module._findPath, and it is why packages can expose an entry point through "main": "lib/index.js".

The extension registry explains an older tool pattern as well. CoffeeScript, TypeScript transpilers, and other language tools used to install handlers such as require.extensions['.coffee'] = ... to compile non-JS files during require(). The hook still works in Node v24, but it is officially deprecated because it is synchronous, hard to reason about, and does not compose well with ES Modules. ESM loaders are a separate hook system rather than a drop-in replacement for every CJS extension hook; for application code, pre-compilation is usually cleaner.

There are smaller pieces of polish inside _compile too. When the compiled function throws, V8 needs stack traces to point at the original source lines, even though the CommonJS body ran inside a wrapper function. Node accounts for that wrapper so errors point at the source file rather than a synthetic wrapper line.

The same compile step integrates source maps. If a module contains a //# sourceMappingURL= directive, Node can use it when --enable-source-maps is enabled. Source maps let stack traces refer back to original TypeScript, JSX, or other source locations instead of only the compiled JavaScript lines.

Finally, the content passed to _compile is the raw source string loaded for the module. Before or during compilation, Node handles UTF-8 byte order marks and shebang lines so CLI files beginning with #!/usr/bin/env node still compile as JavaScript. The JavaScript engine does not see the shebang as ordinary source text.

The Full Lifecycle, Start to Finish

Tracing one require('./math') call end to end makes the ordering easier to see:

  1. Module.prototype.require receives './math'.
  2. Module._load('./math', parentModule, false) begins.
  3. Module._resolveFilename resolves it to /home/user/project/math.js.
  4. Module._cache['/home/user/project/math.js'] is checked.
  5. The built-in check fails because this is a file path.
  6. new Module('/home/user/project/math.js', parentModule) creates the module object.
  7. The module is stored in Module._cache before its code runs.
  8. module.load('/home/user/project/math.js') starts.
  9. The .js extension handler is selected.
  10. The file is read synchronously into a source string.
  11. module._compile(sourceString, filename) begins.
  12. The shebang line, if present, is handled.
  13. The CommonJS compile path compiles the source with wrapper parameters.
  14. The compiled function is called with module.exports, require, module, filename, and dirname.
  15. The module body runs and mutates or replaces module.exports.
  16. The wrapper returns, and module.loaded becomes true.
  17. Module._load returns module.exports.
  18. The caller receives the exports and continues.

That whole sequence is synchronous. If reading the file takes 50ms, the process is blocked for 50ms. If evaluation triggers ten nested require() calls, each nested call follows the same path, shortened only by cache hits.

The design makes CommonJS predictable. When require() returns, the module's top-level code has run and its side effects are complete. Its exports are complete too, except for the partial values intentionally exposed through circular dependencies.

The cost of that predictability is synchronous disk I/O and synchronous compilation. ES Modules use a different parse, link, and evaluate pipeline, with asynchronous steps when top-level await or loader behavior requires them. Both systems coexist in Node v24, and their interop is covered later in this module-system chapter.