Skip to content

module: cache negative stat results in the CJS loader - #64682

Open
maxday wants to merge 2 commits into
nodejs:mainfrom
maxday:maxday/cjs-loader-negative-stat-cache
Open

module: cache negative stat results in the CJS loader#64682
maxday wants to merge 2 commits into
nodejs:mainfrom
maxday:maxday/cjs-loader-negative-stat-cache

Conversation

@maxday

@maxday maxday commented Jul 22, 2026

Copy link
Copy Markdown

module: cache negative stat results in the CJS loader

Summary

The CommonJS loader keeps a per-require-tree statCache to avoid re-stat-ing the same path while resolving a module tree. Today it only caches successful stats, negative results (e.g. -ENOENT) fall through and are re-probed every time the same missing path is looked up again within the same top-level require.

Failed probes are extremely common during resolution, and the same misses recur across sibling and descendant modules:

  • tryExtensions resolves an extensionless specifier (e.g. require('./helper')) by stat-ing each candidate in order: ./helper.js, then ./helper.json, then ./helper.node, until one exists. Every extension tried before the real one is a miss.
  • Bare specifiers (require('foo')) walk the node_modules chain upward: …/a/b/node_modules, …/a/node_modules, …/node_modules. Most of those parent directories don't exist, so each is a negative stat and every bare require in the tree re-walks the same non-existent ancestors.

Because negatives aren't cached, these misses are re-stat-ed repeatedly within a single resolution pass.

Why this change is safe

The statCache is tree-scoped: it is created when a top-level require begins (requireDepth === 0) and set back to null when it completes. So the staleness window for any cached entry is bounded to the duration of a single top-level require.

That window already exists for positive results: a file cached as "exists" could be deleted mid-traversal and the cache wouldn't notice. Caching a negative result has the exact same bounded window: a file cached as "missing" could be created mid-traversal and the cache wouldn't notice.

Impact

The larger and deeper the dependency tree, the more the same missing paths get re-probed, so real-world node_modules trees are exactly where repeated negative stats add up. The gain scales with how resolution-heavy the tree is and how expensive each syscall is (it is largest when the OS filesystem cache is cold, e.g. the very first run after boot).

It is especially impactful on AWS Lambda. Lambda cold starts are the worst-case for this cost: the filesystem cache is cold, the vCPU share is small so syscalls are relatively expensive, and startup latency is directly user-facing and billed. This is precisely the environment where eliminating repeated negative stats pays off most.

Measured on AWS Lambda (provided:al2023, 128 MB, N=30 cold starts) with a resolution-heavy dependency tree (~1093 modules, each doing bare-specifier node_modules walks plus extensionless/missing tryExtensions probes):

Time spent in the top-level require() call, measured by wrapping it in process.hrtime.bigint():

stat current with the fix delta
mean 1036.3 ms 905.1 ms βˆ’12.7%
median 986.8 ms 916.4 ms βˆ’7.1%
p90 1305.8 ms 983.7 ms βˆ’24.7%

Test

test/parallel/test-module-negative-stat-cache.js verifies that negative (not-found) stat results are cached. The stat cache is populated and read internally by the loader, so it is not directly observable from user code. The test makes it observable by mutating the filesystem between two probes of the same path within one require tree.

Fixes: #64681

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/loaders

@nodejs-github-bot nodejs-github-bot added module Issues and PRs related to the module subsystem. needs-ci PRs that need a full CI run. labels Jul 22, 2026
The CommonJS loader keeps a per-require-tree `statCache` to avoid
re-stat-ing the same path while resolving a module tree, but it only
caches successful stats. Negative results (e.g. -ENOENT) fall through
and are re-probed every time the same missing path is looked up again
within the same top-level require.

These misses are extremely common during resolution and recur across
sibling and descendant modules: `tryExtensions` probes .js/.json/.node
in order (every extension before the real one is a miss), and bare
specifiers walk the node_modules chain upward through many non-existent
ancestor directories. None of these negatives were cached, so they were
re-stat-ed repeatedly within a single resolution pass.

Cache negative stat results alongside positive ones. The staleness
window is identical and already accepted for positive results: the
cache is tree-scoped, created when a top-level require begins
(requireDepth === 0) and cleared when it completes, so a stale entry
can only survive the duration of one top-level require.

Signed-off-by: Maxime David <maxday@amazon.com>
@maxday
maxday force-pushed the maxday/cjs-loader-negative-stat-cache branch from ea93d9e to 7849455 Compare July 22, 2026 19:47
@maxday

maxday commented Jul 22, 2026

Copy link
Copy Markdown
Author

force pushing to add the missing : "Signed-off-by:" in the commit message

@codecov

codecov Bot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

βœ… All modified and coverable lines are covered by tests.
βœ… Project coverage is 90.18%. Comparing base (db3a8d8) to head (7d9cb3b).
⚠️ Report is 101 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #64682      +/-   ##
==========================================
+ Coverage   90.14%   90.18%   +0.03%     
==========================================
  Files         741      746       +5     
  Lines      242133   242787     +654     
  Branches    45568    45740     +172     
==========================================
+ Hits       218265   218950     +685     
+ Misses      15371    15332      -39     
- Partials     8497     8505       +8     
Files with missing lines Coverage Ξ”
lib/internal/modules/cjs/loader.js 98.29% <100.00%> (+0.14%) ⬆️

... and 137 files with indirect coverage changes

πŸš€ New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • πŸ“¦ JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Renegade334

Copy link
Copy Markdown
Member

This was the behaviour in days of yore (#36638) so this is effectively a reversion of that change, hence the failing tests.

One would expect that the impact of the scenario described here is far more common than the need to modify the module tree on-the-fly after a failed require(), which was the motivating case for that change. However, this PR does reintroduce the edge case where the following will fail on both loads, as opposed to the current behaviour where the second require() picks up the new module:

const jsonModule = path.resolve('./test.json')

try {
  const m = require(jsonModule)
  console.debug(m)
} catch (e) {
  console.error(e)
}

fs.writeFileSync(jsonModule, '{}\n')

try {
  const m = require(jsonModule)
  console.debug(m)
} catch (e) {
  console.error(e)
}

FWIW, there aren't any module benchmarks which really capture the scenario of repeated attempts at resolving the same module specifier(s) and/or having to walk several levels up the directory tree, which would probably help make the case for this PR. The original change was accepted in the context of a neutral impact on the module benchmark suite at the time.

@Renegade334 Renegade334 added the semver-major PRs that contain breaking changes and should be released in the next major version. label Jul 22, 2026
@Sanjays2402

Copy link
Copy Markdown

the behavior change here is that a path missing on first probe is now invisible for the rest of the require tree. before this, negative results were never cached, so a file created mid-tree (codegen that writes then requires, some transpile-on-demand setups) would be picked up on a later require in the same tree β€” now it serves the cached miss. the test actually locks that new behavior in. is that regression considered acceptable, or should negative caching be gated so only the "clearly hot" probes (tryExtensions / node_modules walk) cache, not arbitrary require targets?

Caching every negative stat reverted the behaviour of
nodejs#36642 and broke
parallel/test-module-cache: a module missing on a failed require() and
then created was no longer picked up by a later require() in the same
tree.

Narrow the negative caching to speculative probes -- paths resolution
guesses at rather than paths the user named: the extension candidates
tried by `tryExtensions` and the node_modules ancestors walked for bare
specifiers. A cached negative is likewise only read back by a
speculative probe, so a stat of a user-named path always re-stats. That
restores the nodejs#36642 behaviour while keeping the repeated misses, which
are where the win comes from, out of the filesystem.

Rewrite test-module-negative-stat-cache to assert both halves of the
scoped behaviour, and add a benchmark. The benchmark spawns its workload
as a child process's main module because `benchmark/common.js` invokes
main() from a process.nextTick callback, by which point statCache is
already null. At deps=200 depth=12 it shows ~8% improvement.

Signed-off-by: Maxime David <maxday@amazon.com>
@maxday

maxday commented Jul 29, 2026

Copy link
Copy Markdown
Author

Thanks both! I've reworked the PR.

On the regression: All the CI failures were the same test, parallel/test-module-cache, i.e. exactly the behaviour #36642 locked in. I've scoped the negative caching instead of applying it everywhere, which is close to what @Sanjays2402 suggested:

  • Negative results are cached only for speculative probes: paths that resolution guesses at rather than paths the user named: the extension candidates in tryExtensions, and the node_modules ancestor directories walked for bare specifiers.
  • A cached negative is also only read back by a speculative probe, so a stat of a user-named path always re-stats.

So create-then-require in the same tree still works, and test-module-cache.js passes unmodified. The negative cache only covers paths the user never asked for, where create-mid-tree isn't a meaningful pattern.

On the benchmark: Added benchmark/module/module-resolve-misses.js. A module nested depth levels down requires deps distinct bare specifiers: each walk re-probes the same missing node_modules ancestors.

config main with the fix delta
deps=200 depth=4 12.9 13.3 +2.6% (t=6.4)
deps=200 depth=12 12.2 13.2 +8.3% (t=24.7)

(higher is better; 10 runs each)

Failed statx calls, same workload at depth 12: 3000 β†’ 612 (βˆ’80%), and the saving scales with depth, as the mechanism predicts.

@Renegade334 on the test.json example you posted: with this version the second require() picks up the new file, since that's a user-named path.

Let me know!

@maxday

maxday commented Jul 30, 2026

Copy link
Copy Markdown
Author

Also, now that the previous tests are passing, I'm not sure this is a breaking change anymore. Should we remove the semver-major tag? Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

module Issues and PRs related to the module subsystem. needs-ci PRs that need a full CI run. semver-major PRs that contain breaking changes and should be released in the next major version.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

module: CJS loader re-stats the same missing paths within one require tree

4 participants