Skip to content

module: add clearCache for CJS and ESM#61767

Open
anonrig wants to merge 11 commits intonodejs:mainfrom
anonrig:yagiz/node-module-clear-cache
Open

module: add clearCache for CJS and ESM#61767
anonrig wants to merge 11 commits intonodejs:mainfrom
anonrig:yagiz/node-module-clear-cache

Conversation

@anonrig
Copy link
Member

@anonrig anonrig commented Feb 10, 2026

Introduce Module.clearCache() to invalidate CommonJS and ESM module caches with optional resolution context, enabling HMR-like reloads. Document the API and add tests/fixtures to cover cache invalidation behavior.

@nodejs-github-bot
Copy link
Collaborator

Review requested:

  • @nodejs/loaders

@nodejs-github-bot nodejs-github-bot added esm Issues and PRs related to the ECMAScript Modules implementation. module Issues and PRs related to the module subsystem. needs-ci PRs that need a full CI run. labels Feb 10, 2026
@anonrig anonrig force-pushed the yagiz/node-module-clear-cache branch 2 times, most recently from 90303e6 to 1d0accc Compare February 10, 2026 21:25
@anonrig anonrig added semver-minor PRs that contain new features and should be released in the next minor version. notable-change PRs with changes that should be highlighted in changelogs. labels Feb 10, 2026
@github-actions
Copy link
Contributor

The notable-change PRs with changes that should be highlighted in changelogs. label has been added by @anonrig.

Please suggest a text for the release notes if you'd like to include a more detailed summary, then proceed to update the PR description with the text or a link to the notable change suggested text comment. Otherwise, the commit will be placed in the Other Notable Changes section.

@mcollina
Copy link
Member

I’m relatively +1 on having this in Node.js, but I recall having a a lot of discussions about this @GeoffreyBooth and @nodejs/loaders teams about this, and it would massively break the spec, expectations, and invariants regarding ESM.

(Note, this is what people have been asking us to add for a long time).

My personal objection to this API is that it would inadvertently leak memory at every turn, so while this sounds good in theory, in practice it would significantly backfire in long-running scenarios. An option could be to expose it only behind a flag, putting the user in charge of choosing this behavior.

Every single scenario where I saw HMR in Node.js ends up in memory leaks. This is the reason why I had so much interest and hopes for ShadowRealm.

Copy link
Member

@benjamingr benjamingr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am still +1 on the feature from a user usability point of view. Code lgtm.

@benjamingr
Copy link
Member

Every single scenario where I saw HMR in Node.js ends up in memory leaks. This is the reason why I had so much interest and hopes for ShadowRealm.

We're giving users a tool, it may be seen as a footgun by some but hopefully libraries that use the API correctly and warn users about incorrect usage emerge.

@anonrig
Copy link
Member Author

anonrig commented Feb 10, 2026

@mcollina Thanks for the feedback. I agree the ESM semantics concerns are real. This API doesn’t change the core ESM invariants (single instance per URL); it only removes Node's internal cache entries to allow explicit reloads in opt‑in workflows. Even with that, existing references (namespaces, listeners, closures) can keep old graphs alive, so this is still potentially leaky unless the app does explicit disposal. I’ll make sure the docs call out the risks and the fact that this only clears Node’s internal caches, and I’d like loader team input on the final shape of the API.

This commit should address some of your concerns. b3bd79a

I am still +1 on the feature from a user usability point of view. Code lgtm.

Thanks for the review @benjamingr. Would you mind re-reviewing again so I can trigger CI?

@Nsttt
Copy link

Nsttt commented Feb 10, 2026

Thanks a lot for this ❤️

@Jamesernator
Copy link

Jamesernator commented Feb 10, 2026

Rather than violating ESM invariants, can't node just provide a function that imports a url?

i.e. While the given example of:

const url = new URL('./mod.mjs', import.meta.url);
await import(url.href);

clearCache(url);
await import(url.href); // re-executes the module

is indeed not spec compliant, it's perfectly legal to have something like:

import { clearCache, importModule } from "node:module";

await importModule(someUrl);
clearCache();
await importModule(someUrl); // reexecute

@codecov
Copy link

codecov bot commented Feb 10, 2026

Codecov Report

❌ Patch coverage is 86.15702% with 67 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.71%. Comparing base (2e1265a) to head (1410c00).
⚠️ Report is 16 commits behind head on main.

Files with missing lines Patch % Lines
lib/internal/modules/esm/loader.js 35.08% 37 Missing ⚠️
lib/internal/modules/clear.js 91.79% 26 Missing and 1 partial ⚠️
lib/internal/modules/esm/module_map.js 91.17% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #61767      +/-   ##
==========================================
- Coverage   89.72%   89.71%   -0.02%     
==========================================
  Files         676      677       +1     
  Lines      206065   206553     +488     
  Branches    39508    39597      +89     
==========================================
+ Hits       184897   185301     +404     
- Misses      13315    13383      +68     
- Partials     7853     7869      +16     
Files with missing lines Coverage Δ
lib/internal/modules/esm/translators.js 97.84% <100.00%> (+0.20%) ⬆️
lib/module.js 100.00% <100.00%> (ø)
lib/internal/modules/esm/module_map.js 96.96% <91.17%> (-1.51%) ⬇️
lib/internal/modules/clear.js 91.79% <91.79%> (ø)
lib/internal/modules/esm/loader.js 95.23% <35.08%> (-3.53%) ⬇️

... and 29 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.

Copy link
Member

@joyeecheung joyeecheung left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While I am +1 to the idea in general, I am afraid the current API may bring more problem than it solves...see the comments.

(Granted it isn't really a problem unique to this specific design, I think the issue is more that this is not a very well solved problem so far, I don't really know what it should look like, though I think I might be able to point out what it should not look like to avoid adding/re-introducing leaks/use-after-frees that user land workarounds can already manage)

@ScriptedAlchemy
Copy link

ScriptedAlchemy commented Feb 11, 2026

I was the one requesting this while sitting next to yagiz today.
Some context:

We take advantage of Module Federation which allows us to distribute code at runtime. However, when parts of the distributed system are updated, it gets stuck in module cache.

I've had some workarounds, like attempting to purge require cache - however when it comes to esm, it's a difficult problem. Since we do this distribution primarily in production, and there can be thousands of updates a day, I block esm from being supported because it'll leak memory - which was fine for several years but becoming more problematic in modern tooling.

On lambda we cannot just exit a process and bring a new one up without triggering a empty deploy, which has generally been a perf hit to cold start a new lambda vs try and "reset" the module cache for primitive hot reload.

Now, I know this might be controversial, or not recommended - but the reality is that many large companies use federation, most fortune 50 companies use it heavily. All of them are relying on userland cobbling I've created. If there is a solution, it would be greatly appreciated by all of my users.

I believe this would also be very useful in general for tooling like rspack etc where we have universal dev serves.

If invalidation of specific modules causes complexity, I'd be more than happy with a nuclear option like resetModuleCache() which just clears everything entirely. Would be a little slower, but nothing is slower than killing a process and bringing up a new one.

"Soft Restart" node without killing it.
Yes, I'm aware of various footguns like globals, prototype pollution etc.
These so far have been easy to mitigate and none of the user base has reported any major issues around it, whereas my cobbled together solution poses a much bigger issue vs footguns.

Don't have much opinion on spec compliance etc, can go through NAPI as well if that would avoid any spec concerns or pushback.

@jsumners-nr
Copy link

Chiming in to say that re-loading a module is very helpful in tests. We can do this with the fabulous CJS paradigm, but ESM does not have a viable equivalent and it should.

@joyeecheung
Copy link
Member

joyeecheung commented Feb 11, 2026

I think there are still quite a few places that need updates/tests - I tried my best to find them, but there are some dusty corners in the module loader that I have never poked at, you might want to take a heap snapshot or write more tests with v8.queryObject() to verify:

  • What happens when a closure in a module errors (or more specifically when the error stack is prepared by poking at various caches) after the cache of the original module is cleared? Especially if it has source maps and --enable-source-maps is on?
  • This is tricky, but cjsModule[parent] and cjsModule[kLastModuleParent] could need an update too if you yank the parents out of the cache. Otherwise the parent can get leaked.
  • When dynamic import(cjs) happens, there can be a point where the CJS module cache entry for the requested module and its dependencies are synchronously populated for export detection, but they will only be compiled and evaluated in the next microtask queue checkpoint, yet here import() itself can already return since it's async, and some code elsewhere could clear the cache before another checkpoint (likely an await) actually spins the evaluation - in the evaluation callback of cjs facades, it will then try to look up the caches again, and see a mismatch between "module whose exports are detected" v.s. "module that's actually being compiled and evaluated" - races of this kind has been a source of subtle bugs, we sort of made most of them go away by making resolution and loading entirely synchronous, but the cache clearing can expose new internal details that add another bug surface that's worth checking.
  • The cjsCache in the esm translators (there's a TODO about using WeakMap instead, maybe that works?)
  • The wasm facade module has a custom import.meta initializer that contains a closure (implemented in createDynamicModule), which in turn has references crossing the wasm boundary, not sure if that can create another source of leaks.

@anonrig
Copy link
Member Author

anonrig commented Feb 11, 2026

I think I addressed all of your concerns @joyeecheung. Let me know if I missed anything!

@GeoffreyBooth
Copy link
Member

I’m relatively +1 on having this in Node.js, but I recall having a a lot of discussions about this @GeoffreyBooth and @nodejs/loaders teams about this, and it would massively break the spec, expectations, and invariants regarding ESM.

Just pinging @guybedford to speak on the spec concerns. I think we should wait for him or someone similarly knowledgeable about the spec to comment before landing.

In general I'm +1 on the feature, assuming it can be safely implemented. My (dim) recollection was that the last time we considered it, it was impossible to modify an ES module after it had been loaded into V8. Has that changed in recent years? How do you handle cases like import { foo } from './bar.js' where bar.js gets reloaded and no longer has a foo export, and the importing code calls foo()? That was part of the complexity, that ESM has this linking stage and so presumably replaced modules need to have the same shapes/exports or else the linking gets invalidated.

@anonrig anonrig requested a review from guybedford February 12, 2026 01:40
@joyeecheung
Copy link
Member

joyeecheung commented Feb 26, 2026

I checked out the existing HMR solutions a bit and I think this API may be enough:

/**
 * @param {string|URL} specifier  // This is what would've been passed into import(specifier) or require(specifier)
 * @param {{
 *   parentURL: string | URL,  // Mandatory, because parent identity is part of the resolution cache key
 *   importAttributes?: Record<string, string>,  // Optional, only meaningful when resolver is "import"
 *   resolver: "import" | "require",  // Specifies how resolution should be performed
 *   caches: "resolution" | "module" | "all",  // resolution: only clear resolution cache; module: clear cache for the module everywhere in Node.js (not counting JS level references)
 * }} options
 */
function clearCache(specifier, options) {}

clearing resolution cache is still useful for HMR solutions that do cache busting URLs - which I think may actually be the more spec-compliant way to implement it. The spec violation technically doesn't come from evaluation, but from module mapping specified by HostLoadImportedModule:

If this operation is called multiple times with two (referrer, moduleRequest) pairs such that:

  • the first referrer is the same as the second referrer;
  • ModuleRequestsEqual(the first moduleRequest, the second moduleRequest) is true;

and it performs FinishLoadingImportedModule(referrer, moduleRequest, payload, result) where result is a normal completion, then it must perform FinishLoadingImportedModule(referrer, moduleRequest, payload, result) with the same result each time.

The cache clearing API makes it possible for the same referrer + module request to get different module records in return, but it does not mean this must be violating the spec by nature, it just delegates the responsibility of correctness to whoever that uses this API, similar to how V8 delegates this to Node.js. One way to ensure this is correctly implemented is to use a cache busting referrer (i.e. import('./app.js?hmr=1'), import('./app.js?hmr=2')...when the reloaded module is app.js, then it is no longer the same module request anyway).

A minimal example of using this (ignoring some complexities from e.g. fs) can be

let app, rev = 0;

const reload = async () => {
  const prev = rev ? `./app.mjs?hmr=${rev}` : null;
  await app?.dispose?.();  // clear side effects
  if (prev) {
    module.clearCache(prev, {
      parentURL: import.meta.url,
      resolver: "import",
      caches: "all",
    });
  }
  app = await import(`./app.mjs?hmr=${++rev}`);
};

await reload();
http.createServer((req, res) => app.handle(req, res)).listen(3000);
fs.watch(new URL(import.meta.resolve('./app.mjs')), reload);

@nicolo-ribaudo
Copy link
Contributor

@joyeecheung In that example, if ./app.mjs depends on ./dependency.mjs and ./dependency.mjs changes, would ./app.mjs?hmr=2 get the new version of it? Or would it still get the old version because it's in some (fullyResolvedURL -> evaluated module) cache?

@nicolo-ribaudo
Copy link
Contributor

@ScriptedAlchemy @Nsttt the meeting is today at 4pm UTC. If nobody sent you the link yet, feel free to email me at the email on my GitHub profile and I'll send you the meeting URL.

@joyeecheung
Copy link
Member

joyeecheung commented Feb 26, 2026

In that example, if ./app.mjs depends on ./dependency.mjs and ./dependency.mjs changes, would ./app.mjs?hmr=2 get the new version of it? Or would it still get the old version because it's in some (fullyResolvedURL -> evaluated module) cache?

This is a minimal example, but if dependencies need to be supported, the HMR solution can just append hmr parameter to all the dependencies via a loader hook that track the graph through context.parentURL and manage the lifecycle of them, which IIUC is what they already do for CJS anyway (because const { foo } = require('foo') at the top level also can't be broken once evaluated), just using a stable URL because there's no idempotency requirement for CJS. The cache busting method for ESM is already used by a few HMR solutions (e.g. https://adonisjs.com/blog/hmr-in-adonisjs#hot-hook) - what they don't have remaining is to clear the stale cache left behind.

@anonrig anonrig force-pushed the yagiz/node-module-clear-cache branch from d4fb1b4 to 7e9a7c5 Compare February 26, 2026 16:56
@anonrig anonrig requested a review from joyeecheung February 26, 2026 16:56
@joyeecheung
Copy link
Member

joyeecheung commented Feb 26, 2026

I think one way to test this more robustly (i.e. V8 can actually garbage collect it) might be something like this:

// Flags: --expose-internals
const { internalBinding } = require('internal/test/binding');
const { ModuleWrap } = internalBinding('module_wrap');
const { queryObjects } require('node:v8');  // Let's run the test in CJS to reduce the noise from queryObject
let app, rev = 0;
const reload = async () => {
  const prev = rev ? `./app.mjs?hmr=${rev}` : null;
  if (prev) {
    module.clearCache(prev, {
      parentURL: import.meta.url,
      resolver: "import",
      caches: "all",
    });
  }
  app = await import(`./app.mjs?hmr=${++rev}`);
};

(async() {
  await reload();  // first load
  await reload();  // second
  const result = queryObjects(ModuleWrap, { format: 'summary' });
  // Validate that result no longer includes module with a wrap whose .url includes `app.mjs?hmr=0`
})();

(Or use checkIfCollectableByCounting with ModuleWrap)

@anonrig
Copy link
Member Author

anonrig commented Feb 26, 2026

@joyeecheung Pushed a new test according to your recommendations.

@anonrig
Copy link
Member Author

anonrig commented Feb 28, 2026

@joyeecheung @mcollina would you mind re-reviewing?

Copy link
Member

@mcollina mcollina left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@anonrig anonrig added the request-ci Add this label to start a Jenkins CI on a PR. label Feb 28, 2026
@github-actions github-actions bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Feb 28, 2026
@nodejs-github-bot
Copy link
Collaborator

@anonrig
Copy link
Member Author

anonrig commented Feb 28, 2026

@guybedford @joyeecheung @GeoffreyBooth I'd like to land this on Monday, but I want to make sure we are all aligned with this change. Would you mind reviewing or leave a comment about your thoughts? Just don't forget that this is an "active development" API which we can iterate over time.

@thescientist13
Copy link

Just wanted to chime in that since going full ESM a few years ago, this has been one of the biggest things I've been missing over CJS, for live reloading NodeJS code for local development based workflows (been using Worker Threads as a fallback).

Very much looking forward to this one! 💚

}

// Clear resolution cache. Only ESM has a structured resolution cache;
// CJS resolution results are not separately cached.
Copy link
Member

@joyeecheung joyeecheung Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is true, there is relativeResolveCache. Other than that, we also have stat cache in CJS. When the resolution cache needs to be cleared we should clear them - otherwise it can also slowly leak or go stale when the file layout changes, leading to an incorrect resolution the next time. Can you add some tests for them?

We also have package.json caches, though that might be a bit harder to clean - can you add a test to check that if package.json get updated such that the exports condition point to a different resolution, after clearing the cache, the second load correctly resolve to the file pointed to by the updated export condition?

* @returns {string}
*/
function resolveClearCacheURL(specifier, parentURL) {
const parsedURL = getURLFromClearCacheSpecifier(specifier);
Copy link
Member

@joyeecheung joyeecheung Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it bypassing the hooks here? I think for ESM, simply cascadedLoader.resolveSync(parentURL, specifier).url should be enough, the special cases seem to introduce the inconsistency that for absolute paths, the hooks are bypassed, which could break user code that import full URLs + hooks that redirect them - then the module being cleared is incorrectly resolved. Can you add a test to check that when a hook is registered, say that redirect a full path to another path, it's the redirected path's module cache that gets cleared when caches is all?

let request = specifier;
if (parsedURL) {
if (parsedURL.protocol !== 'file:' || parsedURL.search !== '' || parsedURL.hash !== '') {
return null;
Copy link
Member

@joyeecheung joyeecheung Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does returning null mean here? I think for CJS, creating a fake parent and then resolveForCJSWithHooks is enough. Similarly, if we bypass the hook for absolute paths, this may fail to clear the module cache when the resolution is customized.

const cascadedLoader = getOrInitializeCascadedLoader();
let deleteResolveCalls = 0;
const originalDeleteResolveCacheEntry = cascadedLoader.deleteResolveCacheEntry;
cascadedLoader.deleteResolveCacheEntry = function(...args) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Patching a method to check how it's called can be somewhat brittle - I think we can simply expose the resolve cache and check that it's cleared instead?

* @param {string} filename
* @returns {boolean} true if any entries were deleted.
*/
deleteResolveCacheByFilename(filename) {
Copy link
Member

@joyeecheung joyeecheung Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used anywhere? If unused, this can be deleted. Since the resolution cache clearing is differentiated based on resolver, this doesn't seem to be needed (if resolver is import, then just clear that exact same request + parentURL + import attribute entry. If resolver is require, this is not used at all).

const file = path.join(__dirname, 'mod.js');
require(file);

clearCache(file, {
Copy link
Member

@joyeecheung joyeecheung Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use the snippet in #61767 (comment) to demonstrate it beyond clearing only the module cache with an absolute path? That would be a more realistic example (even though it's still relatively naiive).

Also for the import path, I think the documentation deserves a reference to the ECMA262 spec referenced in that comment - if the user re-import the exact same module request (without updating the search parameters), it technically breaks a spec invariant, so it's recommended to change the search parameter for the next load. Otherwise the behavior should be considered undefined, and we can't guarantee its correctness (we are sort of relying on the fact that V8 isn't technically following the spec right now for this to not fail - if V8 gets refactored to follow the spec more closely, not respecting the idempotency requirement in the spec might lead to a CHECK/crash).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you wanted to follow the spec perfectly you would replace invalidated module records with a tombstone record. Then if the user imports a previously-cleared module it should throw. The idempotency invariant is only required in the normal completion case.

}

/**
* Remove load cache entries for a URL and its file-path variants.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems a bit too broad for resolver: "import" - it could be surprising that by clearCache('./foo?t=1', ...) you also clear the cache for ./foo?t=2 - intuitively, one might expect that the latter would remain and won't see a re-load unless you specifically clear it. I think this behavior either needs a call out in the documentation, or become configurable in the API.

Copy link
Member

@GeoffreyBooth GeoffreyBooth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@guybedford @joyeecheung @GeoffreyBooth I'd like to land this on Monday, but I want to make sure we are all aligned with this change. Would you mind reviewing or leave a comment about your thoughts? Just don't forget that this is an "active development" API which we can iterate over time.

I defer to @joyeecheung on the technical review and to @guybedford on the spec compliance, so if both of them approve then it seems good to me!

One thing I'm wondering about is what about people using this in production. It's obviously designed only for development, but nothing stops someone from using this API anywhere, or for dependencies calling it; is that a concern? Could it be a security concern if a malicious dependency calls this in production? I assume not since what this does is basically already possible in CommonJS, but it might be something worth addressing if only in a comment.

`resolver` is `'import'`.
Clears module resolution and/or module caches for a module. This enables
reload patterns similar to deleting from `require.cache` in CommonJS, and is useful for HMR.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we spell out the acronym before this.

Suggested change
reload patterns similar to deleting from `require.cache` in CommonJS, and is useful for HMR.
reload patterns similar to deleting from `require.cache` in CommonJS, and is useful for
hot module reload.

caches: 'module',
});
require(file); // re-executes the module
```
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example is good albeit minimal; should we add an expanded example along the lines of “if you want to implement HMR, this is how you do it”?

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

Labels

esm Issues and PRs related to the ECMAScript Modules implementation. module Issues and PRs related to the module subsystem. needs-ci PRs that need a full CI run. notable-change PRs with changes that should be highlighted in changelogs. semver-minor PRs that contain new features and should be released in the next minor version.

Projects

None yet

Development

Successfully merging this pull request may close these issues.