CommonJS Module Resolution
CommonJS resolution begins with a require() specifier and ends with either a built-in module identifier or the file-backed module that Node will load. Between those two points, the loader has to account for built-in names, relative paths, absolute paths, package names, node_modules lookup, package metadata, and symlink handling. ESM uses a different resolver, which we will cover in the next subchapter.
How CommonJS Resolution Works
Resolution is not only a string-to-file operation. package.json can redirect entry points, define package-name scopes, choose conditional branches, and influence the module format used after a path has been found. The parent module also affects resolution, because self references, private # imports, conditional exports, and symlink behavior can all depend on where the require() call originated.
At the surface, though, the call still starts with a plain specifier string such as 'fs', './utils', or 'lodash'. The CommonJS resolver classifies that string first. From there it either returns a built-in identifier such as 'fs' or walks a synchronous set of filesystem and package metadata rules until it finds the module that should be loaded.

Figure 1 — CommonJS resolution starts by classifying the specifier. Built-ins end immediately, path specifiers enter filesystem probing, and bare names move into package lookup.
The older probing path is still present: try the path as written, try known extensions, and, if the target is a directory, look for an entry point. Newer package metadata sits in front of parts of that path. "exports" controls which package-name entry points are visible to consumers, while "imports" gives a package a private #-prefixed alias map. Once you know which branch ran, most module-not-found failures reduce to a small set of checks.
Three Categories of Specifiers
The resolver begins by sorting the string passed to require() into one of three categories. That classification determines the rest of the algorithm.
Built-in modules match a name in Node's built-in module registry. 'fs', 'path', 'http', and 'crypto' are common examples. They resolve before filesystem probing. The explicit form, such as 'node:fs', also points at the built-in module.
Relative and absolute paths start with './', '../', or '/'. Relative paths are resolved against the directory of the calling module. If a file at /home/app/src/lib/foo.js calls require('./utils'), the resolver starts from /home/app/src/lib/utils, then applies extension and directory probing.
Bare specifiers are everything else: 'lodash', '@scope/pkg', or 'express/lib/router'. These enter the node_modules climbing algorithm, which is the branch most affected by package layout and package metadata.
This classifier lives behind Module._resolveFilename(), the CommonJS entry point for resolution. The ordering is important: the built-in check runs first, then the path check, then the bare-specifier path.
Built-in Modules
Built-in modules short-circuit normal filesystem resolution. When you write require('fs'), Node checks its internal built-in module registry. If the name is requirable, resolution returns immediately: no filesystem access, no stat calls, and no path probing.
const fs = require('fs');
const also_fs = require('node:fs');Both lines resolve to the same built-in module. The node: prefix was added to require() in Node 14.18 / 16.0. It makes the intent explicit and bypasses require.cache entries that might shadow an unprefixed built-in name. Bare built-in names still beat files and packages with the same name during normal resolution, but a manually inserted require.cache.fs entry can shadow require('fs'). Use node:fs when you want the built-in path without cache spoofing.
The built-in registry includes modules that can look like userland package names, including 'assert', 'util', and 'string_decoder'. If you have a local file called assert.js and write require('assert'), normal resolution returns the built-in. To load the local file, the specifier must be a path: require('./assert').
The full list of built-in modules is available at runtime:
const builtins = require('node:module').builtinModules;
console.log(builtins.length, builtins.slice(0, 5));On Node 24.15, builtinModules returns 72 entries. Most regular built-ins appear without the node: prefix, such as 'fs' and 'path'. Entries that require the prefix appear with it, such as 'node:test', 'node:sqlite', 'node:sea', and 'node:test/reporters'. The list also contains underscore-prefixed compatibility modules such as '_http_agent' and '_stream_readable'. Treat those as compatibility details rather than stable application APIs.
Relative and Absolute Paths
When a specifier starts with ./, ../, or /, Node treats it as a filesystem path. Relative paths are anchored at the caller's __dirname; absolute paths are used as written.
That path still goes through several probes. Node does not only check for the exact filename. It can append extensions and, when the target is a directory, search inside the directory for an entry point.

Figure 2 — Path resolution tests the specifier as written, then registered extensions, then directory entry rules. The first existing candidate stops the search.
Extension Probing
If you write require('./utils') and there is no file literally named utils, Node tries these extensions in order:
.js.json.node
Each attempt checks whether that file exists, and the first hit wins. If both utils.js and utils.json exist in the same directory, require('./utils') loads utils.js.
require('./config');That line might resolve to config.js, config.json, or config.node. The .js extension runs through the CommonJS module wrapper. The .json extension reads the file and parses it with JSON.parse(). The .node extension loads a compiled native addon.
Each extension has a handler registered on Module._extensions. User code can technically add another handler:
require.extensions['.txt'] = function(module, filename) {
const content = require('node:fs').readFileSync(filename, 'utf8');
module.exports = content;
};require.extensions is documentation-deprecated and still supported for compatibility. Node 24.15 does not emit a warning for this usage, even with --pending-deprecation. New application code should avoid it because every registered extension participates in resolution and loading for matching files, which makes process-wide behavior harder to reason about.
The .json handler is small. Conceptually, it does this:
Module._extensions['.json'] = function(module, filename) {
const content = fs.readFileSync(filename, 'utf8');
module.exports = JSON.parse(stripBOM(content));
};The stripBOM call removes a UTF-8 byte-order mark if the file has one. JSON files loaded through require() are parsed once, cached as the parsed JavaScript object, and returned by reference on later calls. If one part of the process mutates that object, every other module that required the same file sees the mutation. require('./config.json') does not give each caller a fresh copy.
The .node handler calls process.dlopen(), a thin wrapper around dlopen() on Unix and LoadLibrary() on Windows. The shared library must expose a Node-compatible addon initializer. Classic addons, context-aware addons, and Node-API addons reach that initializer through different macros and symbol paths, so naming one internal symbol would be too narrow for modern addons. Binary addons are platform-specific: a .node file compiled on Linux will not load on macOS, and vice versa. Tools such as node-gyp and prebuild handle the compilation and distribution of these files.
The Exact File Check
Before extension probing starts, Node tries the specifier as an exact path. require('./utils.js') first checks utils.js exactly. If that file exists, resolution is done. If it does not, Node still treats the whole string as the base path for extension probing, so it may try utils.js.js, utils.js.json, and utils.js.node.
That gives require('./utils') and require('./utils.js') slightly different probe sequences. The first tries utils, then utils.js, utils.json, and utils.node, then checks whether utils is a directory. The second starts at utils.js, and if that exact file is missing, the later probes use utils.js as the filename stem.
Including the extension explicitly can skip later successful-path probes because the exact file check wins immediately. Treat that as a small resolution detail, not a general performance rule. Filesystem cache state, storage, and package shape dominate real startup timing. Lint rules that require explicit extensions are usually about portability and matching the runtime contract as much as speed. TypeScript projects using Node-style module resolution also have their own extension substitution rules, which is why you sometimes see .js extensions in TypeScript source that type-check against .ts files while still emitting runtime-compatible JavaScript specifiers.
Directory-as-Module
If require('./mylib') resolves to a directory rather than a file, Node looks inside that directory for an entry point. The order is:
- Read
package.jsonin the directory and check the"main"field - Try
index.js - Try
index.json - Try
index.node
{
"name": "mylib",
"main": "lib/entry.js"
}With that package.json, require('./mylib') loads ./mylib/lib/entry.js. Without a package.json, or without a "main" field, the resolver falls back to index.js in the directory.
The index.js convention comes from Node's earliest days, and many projects still use it. You will often see directories whose index.js file only re-exports things from sibling files. The official docs now call folders-as-modules a legacy feature, but directory entry points remain part of the CommonJS compatibility contract.
When a directory has both a valid "main" field and an index.js, "main" wins. If "main" points to a missing file, Node still has a deprecated recovery path: it tries package-root index.js, index.json, and index.node, and emits DEP0128 for the invalid "main" field. That fallback exists for compatibility; new packages should not rely on it.
// Assuming ./mylib/ has package.json with "main": "entry.js"
const lib = require('./mylib');
// Loads ./mylib/entry.js, ignores ./mylib/index.jsThe directory check itself uses internal stat bindings that return an integer representing the file type. If the stat call indicates a file, the path is handled as a file, with extension probing as needed. If it indicates a directory, the directory-as-module path runs. If the stat call fails with ENOENT, that candidate is exhausted.
The node_modules Climbing Algorithm
Bare specifiers enter the most package-shaped part of CommonJS resolution. require('lodash') has no path prefix, so Node has to find lodash somewhere on the filesystem. It starts from the caller's directory and walks upward, checking for node_modules directories at each level.
Module._nodeModulePaths(from) generates that search list. For a file at /home/app/src/lib/foo.js, the list looks like this:
/home/app/src/lib/node_modules
/home/app/src/node_modules
/home/app/node_modules
/home/node_modules
/node_modules
Figure 3 — Bare package lookup climbs from the caller toward the filesystem root. At each candidate package, "exports" can define the public entry points before legacy "main" and index fallback apply.
Node builds the array by taking the caller's directory, repeatedly removing the last path segment, and appending /node_modules at each level until it reaches the filesystem root. It then tries to resolve the bare specifier as a file or directory inside each of those node_modules directories, in order.
So require('lodash') from /home/app/src/lib/foo.js first checks /home/app/src/lib/node_modules/lodash, then /home/app/src/node_modules/lodash, then /home/app/node_modules/lodash, and so on. At each candidate location, the same extension probing and directory-as-module logic applies. The first match wins.
Why It Climbs
The climbing algorithm lets nested packages have their own dependencies. /home/app/node_modules/express/node_modules/accepts is a separate copy of accepts used by express. A different version of accepts could also exist at /home/app/node_modules/accepts for the main application. npm deduplication tries to hoist shared versions to the highest possible level, but when versions conflict, nested node_modules directories keep them isolated.
That design has a filesystem cost. Before npm v3's flatter install strategy, dependency trees could become deeply nested enough to run into historical Windows path-length limits. npm v3+ flattens the tree aggressively. pnpm takes a different approach, using symlinks to a content-addressable store. The CommonJS resolution algorithm does not care which package manager created the tree; it follows the filesystem structure it sees.
Scoped Packages
Scoped packages add one directory level inside node_modules. require('@babel/core') looks for node_modules/@babel/core/, where @babel is a directory and core is a subdirectory inside it. The climbing algorithm is unchanged; it treats @babel/core as a two-segment path inside each candidate node_modules directory.
Subpath Requires
You can also require files inside a package, as in require('express/lib/router'). The resolver first finds express through the climbing chain, then appends /lib/router and applies the same file and directory probing. That specifier might resolve to node_modules/express/lib/router.js or node_modules/express/lib/router/index.js.
The "exports" field can stop that deep access. If a package defines "exports", package subpaths are blocked unless the map explicitly includes them. A package can expose require('express') and require('express/Router') while preventing require('express/lib/router'). The "exports" map acts as an allow-list for the package's public surface.
Before "exports" existed, any consumer could reach into a package's internal files. Library authors had no package-name scope around files they considered private. Renaming or moving an internal file could break consumers that depended on it, even if the file was never intended as public API. "exports" gives packages a standard way to draw that split for pkg and pkg/subpath specifiers.
package.json "main" Field
When the climbing algorithm finds a matching directory in node_modules, it still has to choose which file in that package to load. The legacy answer is the "main" field in that package's package.json.
{
"name": "lodash",
"version": "4.17.21",
"main": "lodash.js"
}The "main" value is resolved relative to the package directory. If "main" points to "./dist/index.js", the resolved file is /path/to/node_modules/lodash/dist/index.js. If "main" is missing, Node falls back to index.js in the package root.
Some packages use "main" for a CommonJS entry point and provide another field for ESM. "main" comes from the CommonJS era, but Node still uses it when "exports" is absent. The "module" field was invented by the bundler ecosystem and is never officially recognized by Node. The Node-native way to publish separate CJS and ESM entry points is "exports" with conditions.
A common npm package shape looks like this:
{
"name": "some-lib",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js"
}Node ignores "module" completely. Webpack, Rollup, and esbuild may read it when bundling for browser or ESM targets. Node only looks at "main" and "exports". If you are writing a package for both Node and bundlers, conditional "exports" gives Node a standard map, and modern bundlers understand it too.
package.json "exports" Field
The "exports" field is the modern package scope. It was added in Node 12.7 and has grown in capability since. When a package is loaded by name through node_modules lookup or package self-reference, "exports" takes precedence over "main". It also restricts which package-name subpaths can be loaded: anything not listed in "exports" is off-limits through require('pkg/subpath') and import 'pkg/subpath'. A relative folder load such as require('./my-pkg') still uses the legacy folder-as-module path.
{
"name": "my-pkg",
"exports": {
".": "./lib/index.js",
"./utils": "./lib/utils.js"
}
}With that configuration, require('my-pkg') loads ./lib/index.js and require('my-pkg/utils') loads ./lib/utils.js. But require('my-pkg/lib/internal.js') throws ERR_PACKAGE_PATH_NOT_EXPORTED even if the file exists on disk. This is not strong sandboxing. An absolute path such as require('/path/to/node_modules/my-pkg/lib/internal.js') can still load the file. "exports" protects the package specifier interface, not the filesystem.
Conditional Exports
The "exports" field supports conditions, so the selected target can depend on the loading context.
{
"exports": {
".": {
"import": "./lib/index.mjs",
"require": "./lib/index.cjs",
"default": "./lib/index.js"
}
}
}When require('my-pkg') runs, Node matches the "require" condition and loads ./lib/index.cjs. When import 'my-pkg' runs in an ES module, Node matches "import" and loads ./lib/index.mjs. The "default" condition is the fallback when nothing else matches.
Other condition names include "node" for the Node environment, "browser" for bundlers, and custom names such as "development" or "production" when they are enabled with --conditions or -C. You can define arbitrary condition names, but they have no effect unless the consumer knows to activate or interpret them.
Node 24 adds another condition to the CommonJS path when require(esm) support is enabled: "module-sync". For package "exports" and "imports" resolution, CommonJS require() uses the condition set ["node", "require", "module-sync"] by default. If a package puts "module-sync" before "require" in a conditions object, require() can select that branch. Running with --no-require-module removes "module-sync" from the CommonJS condition set.
Condition evaluation follows object order. Node walks the conditions object from top to bottom and picks the first matching key. If "default" appears before "require", the "require" branch is never reached because "default" matches first. The same ordering rule applies to "module-sync", custom conditions, and nested condition objects.
Subpath Patterns
Starting in Node 12.20, "exports" supports wildcard patterns:
{
"exports": {
"./features/*": "./src/features/*.js"
}
}require('my-pkg/features/auth') resolves to ./src/features/auth.js. The * wildcard is a string replacement and can match multiple path segments containing / separators. That means require('my-pkg/features/auth/handler') also matches ./features/*, resolving to ./src/features/auth/handler.js. The Node docs call this "subpath patterns", and one pattern can expose an entire nested directory structure.
Export targets still have path validation. Targets must be relative paths starting with ./, and Node rejects traversal or invalid segments that would escape the package. Patterns are a mapping tool, not a way to expose arbitrary filesystem paths.
Exports vs main Precedence
When both "exports" and "main" exist in package.json, "exports" wins for package-name resolution in Node versions that support it. "main" still works as a fallback for older Node versions and for legacy relative folder loads. In practice, most packages that ship "exports" keep "main" around for backward compatibility.
package.json "imports" Field
The "imports" field is the package-internal counterpart to "exports". Where "exports" defines what consumers can load, "imports" defines private aliases that only the package itself can use.
{
"name": "my-app",
"imports": {
"#utils": "./src/utils/index.js",
"#db": "./src/database/client.js"
}
}Inside any file belonging to my-app, require('#utils') resolves to ./src/utils/index.js. The # prefix is mandatory because it distinguishes import-map entries from bare specifiers. Outside the package, require('#utils') fails because the "imports" field is scoped to the package that defines it.
The same map can use conditions:
{
"imports": {
"#db": {
"development": "./src/database/mock.js",
"default": "./src/database/client.js"
}
}
}Running with node --conditions=development app.js makes require('#db') resolve to the mock. Without that flag, it resolves to the real client.
The benefit is not only nicer-looking specifiers. Deep relative paths such as require('../../../../utils/helpers') are brittle because moving the current file changes the route back to the shared helper. An "imports" entry stays stable wherever the requiring file lives inside the package.
Resolution for #-prefixed specifiers is different from bare package resolution. Node does not climb the filesystem looking for node_modules. It finds the nearest package scope for the calling file, reads the "imports" field from that package's package.json, and resolves the mapping. If that package has no matching "imports" entry, the specifier fails immediately. Node does not fall back to package.json files higher in the tree.
That scoping is intentional. The "imports" field belongs to one package. A dependency can define its own #utils mapping without conflicting with yours, because Package A's #utils and Package B's #utils each resolve against their own nearest package scope.
NODE_PATH
NODE_PATH is an environment variable containing additional directories to search for modules. It is colon-separated on Unix and semicolon-separated on Windows. Directories in NODE_PATH are searched after the normal node_modules climbing algorithm is exhausted.
NODE_PATH=/home/shared/libs:/opt/custom/modules node app.jsWith that POSIX-shell command, require('some-lib') checks the usual node_modules locations first. If the module is not found there, Node tries /home/shared/libs/some-lib and /opt/custom/modules/some-lib.
NODE_PATH is legacy. The Node docs describe it as "for compatibility reasons" and recommend against using it. You may still see it in Docker containers with shared module directories, monorepo setups that predate npm workspaces, or CI environments with pre-cached dependencies. For normal development, node_modules is the expected mechanism.
Global Folders
Beyond NODE_PATH, Node has a few more global locations it can check as a last resort:
$HOME/.node_modules$HOME/.node_libraries$PREFIX/lib/node
$PREFIX is Node's configured install prefix, which may not be the path a user expects from a version manager or container image. For diagnostics, inspect the runtime list directly:
console.log(require('node:module').globalPaths);These paths exist for historical reasons. Modern applications rarely use them, and they are the final directories checked before the module-not-found error is thrown.
require.resolve()
require.resolve() runs the full CommonJS resolution algorithm and returns the resolved identifier without executing the module or creating a require.cache entry for it. For file-backed modules, that identifier is usually an absolute filename. For built-ins, it is the built-in specifier.
const p = require.resolve('lodash');
console.log(p);That might print /home/app/node_modules/lodash/lodash.js. require.resolve('fs') returns 'fs', and require.resolve('node:fs') returns 'node:fs'. If the module cannot be found, it throws MODULE_NOT_FOUND, just as require() would.
You can pass options to control the search:
require.resolve('lodash', {
paths: ['/custom/search/path']
});The paths option replaces the caller-derived starting locations. Each entry becomes a starting point for the normal node_modules hierarchy search, and Node still includes global folders such as $HOME/.node_modules unless global search paths are disabled. Built-in module names still short-circuit.
The related require.resolve.paths() method returns the directories that require() would search for a given specifier:
const dirs = require.resolve.paths('lodash');
console.log(dirs);The output is the same list Module._nodeModulePaths() generates: the climbing chain from the caller's directory up to root, plus any NODE_PATH entries and global folders. For core modules, require.resolve.paths('fs') returns null because no filesystem lookup is needed.
Because it resolves without evaluating the target, require.resolve() is useful for conditional loading, finding package roots, and debugging resolution issues. A common optional-dependency pattern looks like this:
let yamlPath;
try {
yamlPath = require.resolve('js-yaml');
} catch (err) {
if (err.code !== 'MODULE_NOT_FOUND') throw err;
}
const yaml = yamlPath ? require(yamlPath) : null;The require.resolve() call tests whether js-yaml exists without executing it. If resolution fails, the optional feature can degrade gracefully. If the package exists but throws during evaluation, the error still surfaces, which is usually the right behavior. A broken optional dependency should fail loudly after Node has already found it.
The same tool can locate a package root:
const path = require('node:path');
const pkgDir = path.dirname(require.resolve('lodash/package.json'));
console.log(pkgDir);That resolves lodash's package.json, then strips the filename to get the package directory. From there you can read the package version, find other files, or construct paths relative to the package root. This pattern only works when the package has no "exports" field or explicitly exports ./package.json. Node may read package.json internally for resolution, but that does not make it publicly importable through the package scope.
Symlink Behavior
When the resolution algorithm finds a file, it calls fs.realpathSync() on the path before using it as the cache key. Symlinks are resolved to their real targets.
Suppose you have two symlinks:
/home/app/node_modules/my-pkg -> /opt/packages/my-pkg
/home/app/vendor/my-pkg -> /opt/packages/my-pkgBoth point to the same real directory. When require() loads either path, the cache key is the real path /opt/packages/my-pkg/index.js. The module is loaded once, cached once, and both require('my-pkg') and any resolution through /home/app/vendor/my-pkg return the same exports object.
That behavior is important in symlink-heavy installs. Within one Node process, two symlinked paths that land on the same real file collapse to one cache entry by default. This can avoid duplicate module instances, but it also means the real path controls where that module looks for its own dependencies.
--preserve-symlinks disables realpath resolution for this purpose. With that flag, the symlink path itself becomes the cache key. Two symlinks to the same file become two separate module instances, each with its own exports object. The flag exists mainly for cases where the file's apparent location is significant, including module systems that expect __dirname to reflect the symlink location rather than the target location.
--preserve-symlinks-main applies the same idea only to the main entry script, the file passed to node. Without this flag, node /path/to/symlink.js resolves the symlink before determining __dirname for the entry module. With it, __dirname reflects the symlink's location. Both flags are rare in practice. Most codebases never need them. But if npm link or pnpm produces duplicate module instances, for example causing instanceof checks to fail across what looks like the same package, the symlink resolution step is where to look.
Do not rely on realpath to normalize filename casing. Node's own docs warn that on case-insensitive filesystems, different resolved filenames can point at the same file while still producing different cache entries. That gives you two module instances with separate state. On case-sensitive filesystems, an incorrectly cased specifier simply fails.
Inside Module._resolveFilename
The resolution algorithm lives in lib/internal/modules/cjs/loader.js, inside Module._resolveFilename(request, parent, isMain, options). Reading that function shows the exact order of operations, including details that higher-level descriptions tend to flatten.
The function receives four parameters. request is the specifier string. parent is the Module object for the calling module, or null for the entry point. isMain indicates whether this is the main module being loaded by node app.js. options is the optional configuration object passed by require.resolve().
The first check is for built-in modules. In Node 24, that path goes through BuiltinModule.normalizeRequirableId(request), which checks the specifier against the built-in registry compiled into the Node binary. If the check passes, _resolveFilename returns the specifier string as-is. No file path is needed, and the node: prefix is normalized as part of that lookup.
If the request is not a built-in, _resolveFilename builds the list of search paths. For relative specifiers, the search path is the directory of the parent module, extracted from parent.filename. Absolute specifiers are handled later by _findPath as absolute filesystem paths. For bare specifiers, _resolveFilename calls Module._resolveLookupPaths(request, parent), combining the caller's module.paths climbing chain with any global lookup paths. The result is an array of directories to probe.
Before package self-reference and node_modules lookup, _resolveFilename handles #-prefixed package imports. It finds the nearest package scope for the parent file, reads that package's "imports" field, and resolves the mapping through the same package resolver used by ESM. If the nearest package scope has no matching import-map entry, Node does not climb to an outer package's "imports" map.
With the paths array ready, the function calls Module._findPath(request, paths, isMain). This is where the filesystem work happens. _findPath has its own internal cache, keyed by request + '\x00' + paths.join('\x00'). If that cache has an entry, _findPath returns immediately. Cache misses are where stat calls and package metadata reads happen.
For each directory in the paths array, _findPath constructs a candidate filename. If the request is a relative path, it joins the directory with the request. If the request is a bare specifier inside a node_modules search, it joins the node_modules path with the specifier.
Then it calls tryFile(basePath), a wrapper around the fast C++ binding internalModuleStat. The helper returns the path if the file exists, or false otherwise. Successful full-path resolutions are stored in Module._pathCache, so the same (request, paths) lookup can skip filesystem probing later.
If tryFile fails, _findPath calls tryExtensions(basePath, exts). In a normal process, exts comes from the keys of Module._extensions, usually '.js', '.json', and '.node'. If user code or tooling registers more handlers, those extensions participate too, and the mutable key order affects probing. The first match wins.
For bare package requests, _findPath tries resolveExports() against each candidate node_modules directory before falling back to old file and folder probing. That is where package "exports", conditional exports, subpath matching, and pattern expansion run. If "exports" does not apply and the candidate path is a directory, tryPackage(basePath) reads package.json, extracts "main", and resolves the legacy folder entry point.
If none of the probes succeeds across the search directories, _findPath returns false, and _resolveFilename throws MODULE_NOT_FOUND with the familiar message: Cannot find module 'whatever'.
The Synchronous Cost
CommonJS resolution performs synchronous filesystem work during loading. internalModuleStat(), package metadata reads, and fs.realpathSync() for symlink resolution all run before control returns to your JavaScript. The event loop, covered in Chapter 1, is blocked during that work. Node avoids allocating full JavaScript fs.Stats objects for many probes, but failed path checks and package metadata reads still add startup cost in large applications, especially on slow filesystems, network mounts, some Docker volume configurations, or deeply nested package trees.
Each unsuccessful stat call is wasted work. If require('lodash') runs from a file deep in src/lib/utils/helpers/, the algorithm can check several intermediate node_modules directories before finding the package at the project root. _pathCache mitigates repeated lookups within the same process, but the first resolution of each unique (specifier, paths) pair pays the probing cost.
Those stat calls are visible in profiling. strace on Linux or dtrace on macOS can show stat-family syscalls during startup, many of them returning ENOENT for node_modules directories that do not exist at intermediate path levels. The exact count and timing depend on the application, platform, storage, filesystem cache state, and package tree.
Tools such as module-alias try to reduce that work by rewriting specifiers or changing lookup behavior. Bundlers such as webpack and esbuild avoid the runtime cost by resolving everything at build time and producing a bundle with no equivalent runtime package lookup for those imports.
There is one more filesystem step after a path is found. Unless symlink preservation is enabled, fs.realpathSync() canonicalizes the final path. On Linux, this involves filesystem work to resolve path components and links. On macOS, the platform realpath behavior handles the same job. The --preserve-symlinks flag skips this step for non-main modules. Test it only when profiling shows realpath work or duplicate module identity problems, because it changes cache keys and dependency lookup roots.
The CommonJS loader also passes an internal realpath cache to its helper, so repeated canonicalization can reuse cached path components inside the process. You do not need to account for this in normal application code. It becomes relevant when startup profiling shows realpath work in the flamegraph, at which point --preserve-symlinks is the knob to test.
The Resolution Cache
The loaded module cache and the resolution cache are separate. Module._load() uses the resolved filename as the require.cache key for the loaded Module object. _findPath has its own string-keyed Module._pathCache that stores the mapping from (request, paths) to the resolved file path. As a result, require.resolve() can warm the resolution cache without loading or evaluating the target module.
You can observe the loaded module cache through require.cache, which is a reference to Module._cache:
console.log(Object.keys(require.cache));Each key is an absolute file path after symlink resolution. The values are Module objects with exports, filename, loaded, children, and other properties.
Deleting a specific entry from require.cache forces that file to load again on the next require() call. Some hot-reloading tools use this technique: delete the cache entry, then require the file again to pick up changes. The approach is fragile because the parent module can still retain the old module object in its children array, and any module that already captured a reference to the old exports will not see updates. Tools such as nodemon usually avoid this by restarting the entire process.
delete require.cache[require.resolve('./myModule')];
const fresh = require('./myModule');The first line removes the cached module object. The second line loads and evaluates the module again, producing a new exports object for that filename. Resolution may still hit Module._pathCache, so deleting require.cache is not the same as clearing every loader cache.
Debugging Resolution
When a module will not resolve, start with supported diagnostics.
require.resolve() is the first check. If it throws, the module cannot be found from that location. If it returns a path you did not expect, the path itself usually explains the problem: perhaps a different version is being picked up from a higher-level node_modules.
Module._nodeModulePaths(process.cwd()) shows the directories that would be searched from the current working directory:
const Module = require('node:module');
console.log(Module._nodeModulePaths(process.cwd()));require.resolve.paths('some-pkg') shows the search paths for a specific specifier, including NODE_PATH and global directories.
For core-loader logging, use NODE_DEBUG=module. The DEBUG environment variable does not help here; that is a userland convention. NODE_DEBUG=module prints module-loader messages to stderr, including load requests, cache checks, lookup paths, and loaded filenames. The output is version-shaped and verbose, so treat examples as representative rather than stable text.
In a POSIX shell:
NODE_DEBUG=module node app.js 2>&1 | head -20Typical Node 24.15 output includes lines shaped like these:
MODULE 12345: Module._load REQUEST ./dep parent: .
MODULE 12345: looking for "./dep" in ["/home/app/src"]
MODULE 12345: load "/home/app/src/dep.js" for module "/home/app/src/app.js"The number is the process ID. Search stderr for the specifier you care about, and you can see which branch the loader took.
For targeted development-only tracing, you can preload a small diagnostic module with --require and patch an internal hook:
const Module = require('node:module');
const orig = Module._findPath;
Module._findPath = function(request, paths, ...rest) {
console.log('findPath:', request, paths);
return orig.call(this, request, paths, ...rest);
};That patch touches an internal API. It works for local diagnosis, but it can break across Node releases and should not ship in production. For a one-off command-line check, node --print "require.resolve('some-pkg')" tests resolution from the current directory without changing application code.
Edge Cases Worth Knowing
Self-referencing: Starting in Node 13.1 and 12.16, a package can require() itself by name if it has an "exports" field. Inside a file belonging to my-pkg, require('my-pkg') resolves through the package's own "exports" map. Without "exports", there is no package-scope self-reference branch; the name may still resolve through ordinary node_modules lookup if another package by that name exists. This feature mainly helps packages with subpath exports, letting internal code use the same public API paths that consumers use.
The "type" field: The "type" field in package.json, either "commonjs" or "module", affects whether .js files are treated as CJS or ESM after resolution. The path lookup is mostly the same; the loader behavior after resolution changes. In Node 24.15, a require() call can load a synchronous ES module graph, so a .js file under "type": "module" may load as ESM and return a module namespace object. If that ESM graph uses top-level await, require() throws ERR_REQUIRE_ASYNC_MODULE, and the caller needs dynamic import().
Circular references in resolution: Resolution itself does not have circular-reference problems. The circularity issue with require() happens during loading, when module A requires module B and module B requires module A. Even then, Node handles it by returning a partially constructed exports object. The resolution step, which only turns a string into a path, always terminates because it is a finite filesystem traversal.
Case sensitivity: On case-insensitive filesystems, require('./Utils') and require('./utils') can point at the same file while still producing separate cache entries if their resolved filenames differ. On Linux with a case-sensitive filesystem, one of those specifiers simply fails if the casing does not match the real filename. Enforce import casing in lint or CI, because filesystem differences make these bugs expensive to find late.
package.json without a name: A package.json without a "name" field is fine for resolution. The "main" and "exports" fields still work. The "name" field is for npm publishing and self-reference, but the resolution algorithm only cares about "main", "exports", and "imports".
Relative specifiers and ESM vs CJS context: In a CommonJS file, require('./foo') probes extensions. In an ES module, import './foo' does not probe extensions; you must provide the full filename with extension. The CJS and ESM resolution algorithms differ in meaningful ways, and the ESM side is covered in the next subchapter. Everything here applies specifically to require() resolution.
Multiple package.json files: A project can have package.json files at many levels of the directory tree. The relevant one depends on the branch of resolution. For bare specifier resolution, Node reads the package.json inside the resolved node_modules package. For "imports" resolution, Node uses the nearest package scope above the calling file. In monorepos with nested packages, those are different lookups and can produce different configuration scopes.
Classify the specifier first. Then check the active package scope, the candidate node_modules paths, and the package metadata that can intercept the lookup. If symlinks are involved, compare the apparent path, the real path, and the cache key. Most CommonJS resolution bugs come from one wrong branch, one unexpected package scope, or one cache identity mismatch.