Version
v26.6.0
Platform
Linux DESKTOP-ID7SI66 6.18.33.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Thu Jun 18 21:54:43 UTC 2026 x86_64 GNU/Linux
Subsystem
lib/internal/source_map/source_map_cache.js
What steps will reproduce the bug?
// leak.cjs
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'smcache-'));
fs.writeFileSync(path.join(dir, 'big.js.map'), JSON.stringify({
version: 3, sources: ['a.ts'], sourcesContent: ['x'.repeat(400 * 1024)], names: [], mappings: 'AAAA',
}));
const retained = () => { global.gc(); global.gc(); return (process.memoryUsage().heapUsed / 1e6).toFixed(0); };
console.log(`0 evals -> ${retained()} MB`);
for (let i = 1; i <= 1200; i++) {
// The eval'd source is ~90 bytes and the map is an external file, so V8's own
// retention of eval'd sources is not a factor. Only the sourceURL varies.
(0, eval)(`({f:_=>_()})\n//# sourceURL=file://${dir}/z.js?${i}\n//# sourceMappingURL=big.js.map`);
if (i % 400 === 0) console.log(`${i} evals -> ${retained()} MB`);
}
fs.rmSync(dir, { recursive: true, force: true });
Run the above script twice:
node --expose-gc leak.cjs
node --expose-gc --enable-source-maps leak.cjs
(results below)
Nothing keeps a reference to the evaluated functions and heapUsed is sampled after two forced GCs. So it is retained in memory.
How often does it reproduce? Is there a required condition?
On every run.
The bug is trigger when using --enable-source-maps (or NODE_V8_COVERAGE) plus generated code whose //# sourceURL differs each time it is evaluated (which is often the case with Nextjs HMR).
This bug is also reproduced on v24.20.0 (LTS), v26.6.0 and v26.8.1.
What is the expected behavior? Why is that the expected behavior?
Retained memory stays flat because nothing references the evaluated code, so its source map should be collectable, or at least the cache should be bounded.
What do you see instead?
| Number of Evals |
--enable-source-maps |
no flag |
| 0 evals |
5 MB |
5 MB |
| 400 evals |
170 MB |
5 MB |
| 800 evals |
334 MB |
6 MB |
| 1200 evals |
499 MB |
6 MB |
~415 KB retained per eval (the whole parsed map) which never decreases. The growth is unbounded.
Additional information
Possible fix
maybeCacheSourceMap stores into a strong, never-evicted Map when isGeneratedSource is true:
|
function maybeCacheSourceMap(filename, content, moduleInstance, isGeneratedSource, sourceURL, sourceMapURL) { |
|
const support = getSourceMapsSupport(); |
|
if (!(process.env.NODE_V8_COVERAGE || support.enabled)) return; |
|
const { normalizeReferrerURL } = require('internal/modules/helpers'); |
|
filename = normalizeReferrerURL(filename); |
|
if (filename === undefined) { |
|
// This is most likely an invalid filename in sourceURL of [eval]-wrapper. |
|
return; |
|
} |
|
if (!support.nodeModules && isUnderNodeModules(filename)) { |
|
// Skip file under node_modules if not enabled. |
|
return; |
|
} |
|
|
|
// Bail out when there is no source map url. |
|
if (typeof sourceMapURL !== 'string') { |
|
return; |
|
} |
|
|
|
// Normalize the sourceURL to a file URL if it is a path. |
|
sourceURL = normalizeReferrerURL(sourceURL); |
|
|
|
const data = dataFromUrl(filename, sourceMapURL); |
|
// `data` could be null if the source map is invalid. |
|
// In this case, create a cache entry with null data with source url for test coverage. |
|
|
|
const entry = { |
|
__proto__: null, |
|
lineLengths: lineLengths(content), |
|
data, |
|
// Save the source map url if it is not a data url. |
|
sourceMapURL: data ? null : sourceMapURL, |
|
sourceURL, |
|
}; |
|
|
|
if (isGeneratedSource) { |
|
generatedSourceMapCache.set(filename, entry); |
|
return; |
|
} |
Module sources are weakly keyed through SourceMapCacheMap and stay collectable. Generated sources are not. The comment assumes generated sources are few.
A possible fix would be to bound generatedSourceMapCache with a byte-budgeted LRU, or store the map URL and parse on demand instead of retaining the parsed payload eagerly.
Real-world impact
This is part of a 3.2 GB OOM in a next dev server (next 16.3.4, Turbopack, node 26.6.0).
React's RSC dev machinery calls eval once per stack frame to rebuild owner stacks, appending
//# sourceURL=about://React/<env>/<file>?<counter++> plus an inlined ~525 KB source-map data URL.
The counter makes every key unique. A heap snapshot showed 3866 entries retaining 1457 MB under
context:generatedSourceMapCache which is 480 KB of sourcesContent and 132 KB of mappings per entry.
A few fast edits to one source file killed the process. The same workload is fine on deno and bun,
which do not cache generated source maps.
I am filing the React/Next side separately; this report is only about the cache being unbounded.
For those coming here from a similar next dev issue, use next dev --disable-source-maps as a workaround.
| run |
after first render |
after 12 edits |
per edit |
next dev |
605 MB |
1672 MB |
+89 MB |
next dev --disable-source-maps |
508 MB |
580 MB |
+6 MB |
AI Disclosure
I have used Claude to understand the underlying problem and create parts of the repro. The repro is also verified by me (the human).
Version
v26.6.0
Platform
Subsystem
lib/internal/source_map/source_map_cache.js
What steps will reproduce the bug?
Run the above script twice:
node --expose-gc leak.cjsnode --expose-gc --enable-source-maps leak.cjs(results below)
Nothing keeps a reference to the evaluated functions and
heapUsedis sampled after two forced GCs. So it is retained in memory.How often does it reproduce? Is there a required condition?
On every run.
The bug is trigger when using
--enable-source-maps(orNODE_V8_COVERAGE) plus generated code whose//# sourceURLdiffers each time it is evaluated (which is often the case with Nextjs HMR).This bug is also reproduced on
v24.20.0(LTS),v26.6.0andv26.8.1.What is the expected behavior? Why is that the expected behavior?
Retained memory stays flat because nothing references the evaluated code, so its source map should be collectable, or at least the cache should be bounded.
What do you see instead?
--enable-source-maps~415 KB retained per eval (the whole parsed map) which never decreases. The growth is unbounded.
Additional information
Possible fix
maybeCacheSourceMapstores into a strong, never-evictedMapwhenisGeneratedSourceis true:node/lib/internal/source_map/source_map_cache.js
Lines 157 to 195 in d8ddf46
Module sources are weakly keyed through
SourceMapCacheMapand stay collectable. Generated sources are not. The comment assumes generated sources are few.A possible fix would be to bound
generatedSourceMapCachewith a byte-budgeted LRU, or store the map URL and parse on demand instead of retaining the parsed payload eagerly.Real-world impact
This is part of a 3.2 GB OOM in a
next devserver (next 16.3.4, Turbopack, node 26.6.0).React's RSC dev machinery calls
evalonce per stack frame to rebuild owner stacks, appending//# sourceURL=about://React/<env>/<file>?<counter++>plus an inlined ~525 KB source-map data URL.The counter makes every key unique. A heap snapshot showed 3866 entries retaining 1457 MB under
context:generatedSourceMapCachewhich is 480 KB ofsourcesContentand 132 KB ofmappingsper entry.A few fast edits to one source file killed the process. The same workload is fine on deno and bun,
which do not cache generated source maps.
I am filing the React/Next side separately; this report is only about the cache being unbounded.
For those coming here from a similar
next devissue, usenext dev --disable-source-mapsas a workaround.next devnext dev --disable-source-mapsAI Disclosure
I have used Claude to understand the underlying problem and create parts of the repro. The repro is also verified by me (the human).