Skip to content

module: add clearCache for CJS and ESM#61767

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

module: add clearCache for CJS and ESM#61767
anonrig wants to merge 8 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 from 4f7a659 to 90303e6 Compare February 10, 2026 21:22
@anonrig anonrig force-pushed the yagiz/node-module-clear-cache branch 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 82.47423% with 85 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.71%. Comparing base (3819c7f) to head (fb387a4).
⚠️ Report is 43 commits behind head on main.

Files with missing lines Patch % Lines
lib/internal/modules/esm/loader.js 31.57% 39 Missing ⚠️
lib/internal/modules/clear.js 93.03% 21 Missing and 2 partials ⚠️
lib/internal/modules/esm/module_map.js 58.82% 14 Missing ⚠️
lib/internal/modules/esm/translators.js 85.48% 9 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #61767      +/-   ##
==========================================
- Coverage   89.74%   89.71%   -0.04%     
==========================================
  Files         675      676       +1     
  Lines      204642   205288     +646     
  Branches    39322    39430     +108     
==========================================
+ Hits       183657   184164     +507     
- Misses      13257    13408     +151     
+ Partials     7728     7716      -12     
Files with missing lines Coverage Δ
lib/module.js 100.00% <100.00%> (ø)
lib/internal/modules/esm/translators.js 91.74% <85.48%> (-0.63%) ⬇️
lib/internal/modules/esm/module_map.js 90.30% <58.82%> (-8.18%) ⬇️
lib/internal/modules/clear.js 93.03% <93.03%> (ø)
lib/internal/modules/esm/loader.js 93.40% <31.57%> (-3.57%) ⬇️

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

* `specifier` {string|URL} The module specifier or URL to resolve. The resolved URL/filename
is cleared from the load cache; the specifier (with `parentURL` and `importAttributes`)
is cleared from the resolve cache.
Copy link
Member

Choose a reason for hiding this comment

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

Thinking about it more -what's the point of clearing the resolve cache? For the purpose of HMR, I think clearing the load cache is usually enough, and many user-land solutions never seem to care about the resolve cache. Even in the examples below, it doesn't demonstrate clearing the resolve cache. I think it's better to either split them into two APIs, or add an option that allows users to control which one to clear.

const pathCacheKeys = new SafeSet(ObjectKeys(Module._pathCache));
const relativeResolveCacheKeys = new SafeSet(ObjectKeys(relativeResolveCache));
try {
const filename = resolveClearCacheFilename(specifier, parentPath);
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 hazardous - resolveClearCacheFilename goes through the require path, but the module could've been loaded by import(cjs) (either directly or indirectly) inside a CommonJS module, and in this case module.clearCache should clear the ESM cache too. Otherwise if the CommonJS module also gets to the CJS via require, this re-introduces the dual-package hazard.


if (mode !== 'commonjs') {
try {
const url = resolveClearCacheURL(specifier, parentURL, importAttributes);
Copy link
Member

Choose a reason for hiding this comment

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

Similarly, an ESM module can get to a CommonJS file via both import and require through different paths. Clearing one without another can be hazardous.

Copy link
Member Author

Choose a reason for hiding this comment

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

The more I think about it, I am more convinced that we don't need a complex implementation and support both require/esm modes in this method. Why don't we just clear the cache for both of them at any time, and if people want to explicitly clear cache, we can add that option later?

is cleared from the load cache; the specifier (with `parentURL` and `importAttributes`)
is cleared from the resolve cache.
* `options` {Object}
* `mode` {string} Which caches to clear. Supported values are `'all'`, `'commonjs'`, and `'module'`.
Copy link
Member

@joyeecheung joyeecheung Feb 14, 2026

Choose a reason for hiding this comment

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

I think this isn't a very good choice as an option. For resolution, 'commonjs' and 'module' is not what distinguish how a module should be resolved - it's the conditions: i.e. require or import (and addon, module-sync, node etc). What can be cleared are the "require cache" and "import cache", not "commonjs cache" and "esm cache", because require(esm) and import(cjs) can happen, and they can happen in both types of modules (CommonJS can import(), and ESM can require() via createRequire). If a user wants to clear the cache for require(esm) or import(cjs), the current option is not exactly clear about which one they should choose. Using condition instead of this convoluted mode would fit a lot better for the resolution case.

There's also the question of what happens when the same x.js has been loaded through: import('./x.js?t=1'), import('./x.js?t=2'), require('./x'), I think it's worth either documenting them as quirks in the docs, or just change the behavior to always clear everything for loading because it currently reads like a can of worms:

  1. If x is ESM, but only the ESM cache for 'x.js' is cleared, still none of them will see a re-execution when they happen again. For a third-party tool, they have to intercept all requests from users that resolve to this x.js to make sure all of them get refreshed.
  2. If x is ESM, and only the CJS cache is cleared, then none of these will see a re-execution when they happen again.
  3. If x is CJS, and only the CJS cache is cleared, the imports won't see a re-execution but require will, and you can end up with two copies of the same global state in the graph in conflict with each other
  4. If x is CJS, and only the ESM cache is cleared, then the require won't see a re-execution but import will, and you can end up with two copies of the same global state in the graph in conflict with each other

The latter two again re-introduces the dual package hazard. IMO for loading cache, it's better to always just clear them all and don't even leave an option for partial clears unless anyone specifically ask for them. Also, while 2 and 4 seems silly, the current way of mixing the resolution with loading in the API can very easily trick you into them. Splitting the two APIs helps avoid unintentional partial clears.

@petamoriken
Copy link
Contributor

petamoriken commented Feb 15, 2026

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.

As already pointed out as a concern, I believe this proposal would constitute a violation of the ESM spec. My understanding is that the core ESM invariants is more accurately defined as "evaluation must be only performed once," rather than "single instance per URL." Even if we were to only clear the internal cache in Node.js, for such an action to be permissible under the spec, ECMAScript would require an "escape hatch" that allows the host-defined operations (HostLoadImportedModule is one such example). Currently, no such spec exists in ECMAScript. Therefore, I believe this is a matter that should be discussed within TC39.


Module Harmony (page 14) - Speaker Deck

Copy link
Contributor

@guybedford guybedford left a comment

Choose a reason for hiding this comment

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

This seems very reasonable to me!

In terms of spec compatibility, formally hot reloading does break the host hook invariant, although the reality with loaders already does of course, and perhaps there are wording changes here that might accommodate this better. The resolve cache clearing has some interesting spec integration aspects, since the resolve cache is on the module records themselves in ECMA-262, and also in HTML there's a global resolved set of (specifier, parent) pairs. But the model implemented here seems like it would mostly be consistent with both of those constructs if we ever wanted to consider something standard.

I'd like to add these topics to the agenda at the next TC39 modules coming up next Thursday 26. The meeting is on the public calendar if anyone here interested would like to join. There are no concerns from my side, but I think it's a very interesting spec discussion to have nonetheless.

@ScriptedAlchemy
Copy link

This seems very reasonable to me!

In terms of spec compatibility, formally hot reloading does break the host hook invariant, although the reality with loaders already does of course, and perhaps there are wording changes here that might accommodate this better. The resolve cache clearing has some interesting spec integration aspects, since the resolve cache is on the module records themselves in ECMA-262, and also in HTML there's a global resolved set of (specifier, parent) pairs. But the model implemented here seems like it would mostly be consistent with both of those constructs if we ever wanted to consider something standard.

I'd like to add these topics to the agenda at the next TC39 modules coming up next Thursday 26. The meeting is on the public calendar if anyone here interested would like to join. There are no concerns from my side, but I think it's a very interesting spec discussion to have nonetheless.

I'd like to join. Have link to calendar?

@Nsttt
Copy link

Nsttt commented Feb 17, 2026

Same thing

@anonrig anonrig force-pushed the yagiz/node-module-clear-cache branch from 21bc1e3 to fb387a4 Compare February 17, 2026 20:13
@nicolo-ribaudo
Copy link
Contributor

nicolo-ribaudo commented Feb 17, 2026

I do not have the whole picture, but is we are going to discuss this at the TC39 modules meeting here is my perspective from a spec point of view.

A spec-compliant way of implementing hot reload of a module in a graph would be to re-execute the root of the graph, re-executing all the modules in the path(s) from the root to the updated module, and re-using the previous evaluations for modules in the other branches. This includes both edges created by static imports and by dynamic imports.

For example, consider a graph like this one, with A being the root:
image

If you want to reload F, you'd need to reload/rerun A/B/E/F (let's call them A'/B'/E'/F'), while re-using the existing D/G/H.

Once that's done, all the old copies of the modules that have been re-executed can be garbage collected. There is no module that has already run import("foo") and received the old module, and thus would need to re-receive the old module when re-running import("foo"), because that module is among those that would have been re-executed and thus has only seen the new version of foo.

At this point there is nobody that can, for example, observe that import('F') gives the new module while actually needing to receive the old one:

  • B'/E' only ever saw F' when doing an import of the specifier "F", so in those modules import('F') needs to resolve to F'
  • If any of A'/D/F'/G/H were to do import('F'), it would be the first time that they load the specifier "F", so there is no expectation on whether it needs to resolve to F or F'.

The spec is not incompatible with hot reloading, but it makes sure that you cannot swap the dependencies under an already evaluated module. By doing so it avoids problems like #61767 (comment).

I guess concretely, this would be implemented by counting the number of alive importers of a module, and only allowing to clear the cache from one with no importers alive.

In the example above, after the reloading happens:

  • A has no importers, so it can be removed from the cache
  • this decreases the number of alive importers of B and E to 0, so they can also be removed from the cache
  • this decreases the number of alive importers of F to 0, so it can be removed from the cache.

@jsumners-nr
Copy link

Given the "spec compliant" post above, I think an example of what I think should be possible is warranted.

Consider the case where the thing being tested hooks into module loading. A test suite for such a thing may traditionally look like:

test.beforeEach((ctx) => {
	ctx.namespace = {}
	ctx.namespace.mod = require('something')
})

test.afterEach(() => {
	clearCache('something')
})

test('foo is added', (t) => {
	const { mod } = t.namespace
	t.assert.equal(mod.foo, 'foo')
})

test('bar is added', (t) => {
	const { mod } = t.namespace
	t.assert.equal(mod.bar, 'bar')
})

The key to that example is that the module is completely re-required between each subtest so that the module patching happens prior to each subtest.

As a user, I do not care how it happens, or what the spec folks want in order for it to happen, I just want the same concept to be 1:1 viable with ESM.

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.