Skip to content

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

Open
maxday wants to merge 3 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 3 commits into
nodejs:mainfrom
maxday:maxday/cjs-loader-negative-stat-cache

Conversation

@maxday

@maxdaymaxday 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():

statcurrentwith the fixdelta
mean1036.3 ms905.1 ms−12.7%
median986.8 ms916.4 ms−7.1%
p901305.8 ms983.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-botnodejs-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
maxdayforce-pushed the maxday/cjs-loader-negative-stat-cache branch from ea93d9e to 7849455CompareJuly 22, 2026 19:47
@maxday

Copy link
Copy Markdown
Author

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

@codecov

codecovBot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.26%. Comparing base (db3a8d8) to head (9903df5).
⚠️ Report is 235 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #64682 +/- ##
==========================================
+ Coverage 90.14% 90.26% +0.12% 
==========================================
Files 741 762 +21 Lines 242133 247549 +5416 Branches 45568 46685 +1117 ==========================================
+ Hits 218265 223452 +5187 - Misses 15371 15530 +159 - Partials 8497 8567 +70 
Files with missing linesCoverage Δ
lib/internal/modules/cjs/loader.js98.29% <100.00%> (+0.15%)⬆️

... and 179 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:

constjsonModule=path.resolve('./test.json')try{constm=require(jsonModule)console.debug(m)}catch(e){console.error(e)}fs.writeFileSync(jsonModule,'{}\n')try{constm=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.

@Renegade334Renegade334 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

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.

configmainwith the fixdelta
deps=200 depth=412.913.3+2.6% (t=6.4)
deps=200 depth=1212.213.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

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!

Comment threadlib/internal/modules/cjs/loader.js Outdated
Address review feedback: pass the resolution-probe flag as a named option
instead of a positional boolean, so callsites read
tryFile(basePath + exts[i], { isMain, isSpeculativeProbe: true });
_stat(curPath, { isSpeculativeProbe: true });
`isMain` moves into the bag as well rather than staying positional, and
`stat` gets the same treatment since that is where the flag is consumed.
Both default to `kEmptyObject`, matching the existing idiom in this file.
No behaviour change: the negative-caching scope is identical and the
existing tests pass unmodified.
Signed-off-by: Maxime David <maxday@amazon.com>
@maxday
maxday requested a review from jasnellAugust 3, 2026 15:09
@Renegade334

Renegade334 commented Aug 3, 2026

Copy link
Copy Markdown
Member

FWIW, I get a modest but significant performance improvement on the new benchmark, albeit tested on a disk that's not under load, but only at the higher depth.

 confidence improvement accuracy (*) (**) (***)
module/module-resolve-misses.js n=30 deps=200 depth=12 *** 3.05 % ±1.33% ±1.77% ±2.30%
module/module-resolve-misses.js n=30 deps=200 depth=4 -0.67 % ±1.31% ±1.74% ±2.27%

@Renegade334Renegade334 added the needs-benchmark-ci PR that need a benchmark CI run. label Aug 3, 2026
@maxday

Copy link
Copy Markdown
Author

Thanks @Renegade334
I've noticed you added a new label about benchmark CI, is it something I need to do on my end? or does the benchmark/module/module-resolve-misses.js file I provided is enough?
Let me know! Thanks

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

Labels

moduleIssues and PRs related to the module subsystem.needs-benchmark-ciPR that need a benchmark CI run.needs-ciPRs that need a full CI run.semver-majorPRs 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

5 participants

@maxday@nodejs-github-bot@Renegade334@Sanjays2402@jasnell