Repository navigation
Looping async functions with Promise.race() allocates excessive memory #29385
Description
Activity
- addedconfirmed-bugIssues and PRs for confirmed bugs.Issues and PRs for confirmed bugs.promisesIssues and PRs related to ECMAScript promises.Issues and PRs related to ECMAScript promises.v8 engineIssues and PRs related to the V8 dependency.Issues and PRs related to the V8 dependency.
on Aug 31, 2019 I took a look and I can't really find anything inside Node.js that seems to be at fault. I'm inclined to say this is a V8 issue after all, probably in the way its
Promise.race()implementation retains references to the subordinate promises.If I change your test case like below it runs in constant time and memory. Tweaking
logOutputconfirms it's still behaving the same as before.--- tmp/bug29385.orig.js 2019-08-31 13:02:36.000000000 +0200 +++ tmp/bug29385.js 2019-08-31 13:10:41.000000000 +0200 @@ -5,7 +5,7 @@ const RESULT_OBJECT = new Array(128).fill(0).map(x => ~~(Math.random() * 128)); const STREAM_LENGTH = 10; -const SUITE_SIZE = 10000; +const SUITE_SIZE = 1e5; function deepCloneInner(obj) { /* Some heavy sync operation */ @@ -77,12 +77,22 @@ }, }; +function race(a, b) { + return new Promise(r => { + const k = x => { + if (r) r(x); + r = null; + }; + a.then(k); + b.then(k); + }); +} + async function runGame(logOutput) { let chunk; let x = Promise.resolve(); - // Changing this to chunk = await MY_STREAM.read() removes the leak. - while ((chunk = await Promise.race([MY_STREAM.read(), x]))) { + while ((chunk = await race(MY_STREAM.read(), x))) { if (logOutput) console.log(chunk); } }
I'm reproducing your test case below for posterity.
Details
'use strict'; /* global gc */ const RESULT_OBJECT = new Array(128).fill(0).map(x => ~~(Math.random() * 128)); const STREAM_LENGTH = 10; const SUITE_SIZE = 10000; function deepCloneInner(obj) { /* Some heavy sync operation */ if (obj === null || typeof obj !== 'object') return obj; if (Array.isArray(obj)) return obj.map(prop => deepCloneInner(prop)); const clone = Object.create(Object.getPrototypeOf(obj)); for (const key of Object.keys(obj)) { clone[key] = deepCloneInner(obj[key]); } return clone; } function deepCloneSync(obj) { return deepCloneInner(obj); } async function deepClone(obj) { return deepCloneInner(obj); } async function deepCloneNextTick(obj) { return new Promise((resolve, reject) => { process.nextTick(() => resolve(deepCloneInner(obj))); }); } async function deepCloneSetImmediate(obj) { return new Promise((resolve, reject) => { setImmediate(() => resolve(deepCloneInner(obj))); }); } async function deepCloneAwait(obj) { await 'ayuwoki'; return deepCloneInner(obj); } const MY_STREAM = { iterations: 0, async read() { if (++this.iterations % STREAM_LENGTH === 0) { return null; } /* * Using deepCloneSync() causes a leak of Promises in Node 10.15.3 (V8 6.8.275.32-node.51), * but it doesn't leak in Node 8.15.1 (V8 6.2.414.75) * * Leak also confirmed (Increase SUITE_SIZE x10) in: * node 12.0.0-nightly20190331bb98f27181, v8 7.4.288.13-node.13 * node 12.0.0-v8-canary201903313c649ecee6, v8 7.5.149-node.0 * */ return deepCloneSync(RESULT_OBJECT); // return deepClone(RESULT_OBJECT); // return deepCloneNextTick(RESULT_OBJECT); // return deepCloneSetImmediate(RESULT_OBJECT); // return deepCloneAwait(RESULT_OBJECT); }, }; const ENV = { iterations: 0, getNextGameParameters() { if (++this.iterations % SUITE_SIZE === 0) { return null; } return this.iterations % 2 ? 'arg1' : 'arg2'; }, }; async function runGame(logOutput) { let chunk; let x = Promise.resolve(); // Changing this to chunk = await MY_STREAM.read() removes the leak. while ((chunk = await Promise.race([MY_STREAM.read(), x]))) { if (logOutput) console.log(chunk); } } async function runSuite() { let parameters; let iterations = 0; while ((parameters = ENV.getNextGameParameters())) { await runGame().catch(err => { console.error(`Error for parameters ${parameters}\n${err.stack}`); }); // Uncommenting fixes the leak (Node.js only) /* if (iterations++ % 50 === 0) { await new Promise(resolve => setImmediate(resolve)); } //*/ } } (async () => { await runSuite(); })();
Running the test in V8's simple REPL, d8 doesn't show any growth in memory, see details in the V8 issue. That's why I suspected it has to do with something Node.js specific.
@MayaLekova That was my hunch too. Specifically:
async_hooks- but those aren't enabled when this test runs and that also wouldn't explain why my ersatzPromise.race()doesn't exhibit the same behavior.We're at V8 7.7.299.8-node.12. Have there been upstream changes that might have fixed this? Any other suggestions I could try out?
About the upstream changes - not that I know of, sorry. Does enabling async hooks change anything?
About suggestions - not clear ones, but I'm thinking if there's any difference between how d8 handles the microtask queue vs. how it's embedded in Node.js. For instance I know that d8's
setImmediateimplementation is a dummy one, so there might be something in the actual implementation related to the cause of the leak (why does it supress it?).Enabling async_hooks slows it down by about a factor of 5 but doesn't otherwise impact behavior.
FWIW, when I use a
race()that's a bit more faithful to (my reading of) thePromise.race()spec, I see the same memory consumption as with the built-inPromise.race():function race(promises) { return new Promise((resolve, reject) => { for (const p of promises) p.then(resolve, reject); }); }
I checked the other day whether manually flushing the microtask queue makes any difference but it doesn't. If you want to try for yourself, start node with
--expose-internalsand add this code:const {internalBinding} = require('internal/test/binding'); const {runMicrotasks} = internalBinding('task_queue'); runMicrotasks(); // takes no arguments
Not sure whether it's really important, but I've tried
runMicrotasks()each 50 iterations (as suggested), as well asawait new Promise(resolve => queueMicrotask(resolve))- both don't remove the leak. So it looks like the difference betweenqueueMicrotaskandsetImmediate/setTimeout(0)is what's causing it. Will experiment further, thanks for the extra info!Hi, I've also stumbled on this issue. I wonder, does anyone knows user space workaround that would prevent leaks, like custom impl of
racefunction? Or they only way to makePromise.raceusable is to have that fixed in Node.js source? Thanks!Is seems like it works fine in v13, was that V8 thing after all?
@tardis, reproduced with node-v13.2.0-win-x64. Are you running a different test case or environment?
Hmm, indeed I was running different test case, reverted back to v12.13.1 and it also seems to be working fine - GC is keeping up, must be something with my code then. Sorry for confusion.
Digging in on this a bit... in the original code, if you increase the number of iterations to 100 and run it through the clinicjs.org
clinic doctortool, you'll see that clinic gives you a data analysis error, looking at the underlying trace event file that is collected by clinic, you'll find that the code is getting stuck when running the microtaskqueue. This is caused by the creation of a large number of orphaned Promises in a tight sync loop. If you take a heap snapshot, you'll see that the Promises are being retained by queueMicrotask. I believe the workarounds that have been identified are working only because they end up giving the garbage collector a chance to catch up. If you increase the number of iterations, the workarounds don't appear to work and you'll end up with a memory error being thrown.For d8, it would be interesting to increase the number of iterations to see if the problem occurs there as well. If it doesn't, then it would appear that there is definitely something a bit wonky about the way Node.js is handling the microtaskqueue ... however, just in general I would say that this is yet another reason to avoid using Promise.race(), especially with synchronous loops.
Bit more analysis... with the original code, after enabling trace event tracking using
clinic bubbleand reducing the number of iterations to 5... runninggrep -o '\"PROMISE"\B' 8552.clinic-bubbleprof-traceevent | wc -lreturns579948.I don't believe there's anything actionable for Node.js in this issue. Closing. Can reopen if new information is received that does point to anything we can do in Node.js
- added a commit that references this issue
on Jul 3, 2020 It looks like this was a more complex variant of the simpler repro at #51452 which still exists in Node (and apparently not in V8).
- added a commit that references this issue
on Oct 6, 2025 - added a commit that references this issue
on Oct 20, 2025

Test case: https://gist.github.com/Slayer95/1aed510b091dbacacbb3d4e61704a1a8
What steps will reproduce the problem?
What is the expected output?
Memory consumption is kept constant.
What do you see instead?
An increasingly high memory consumption over time, and the process crashes for OOM.
Supporting info:
Originally reported as https://bugs.chromium.org/p/v8/issues/detail?id=9069
/cc @MayaLekova