Promises in JavaScript are a way to handle asynchronous operations. One of the trickiest part of async operations is that we may accidentally have a memory leak and therefore it's extremely important to understand when the promises is GCed and what prevents a promise from being garbage collected.
TL&DRs
This codesandbox has all the examples for promises and then chains. Open sandbox and reload it. You can run
Promise example
first andthen example
after.In the end of the article you will find key findings from the article.
We will use Please, do not run the experiments in dev tools console. Dev Tools keeps all the objects alive and therefore you will have false-negative results. FinalizationRegistry prints in the console the line and the time spend. It is important to keep in mind that if a promise is eligible for GC, it does not mean that it will be immediately collected. The GC process in JavaScript is not deterministic and occurs at the discretion of the JavaScript runtime.Some notes about tools we will use
This article is for advanced users, it researches how GC (garbage collector) works with promises. If you want to get base information about promises, check earlier articles in the series.
FinalizationRegistry
to find when the element is GCed. Check this article if you're not familiar with FinalizationRegistry
.
To speed up GC we will create tons of mock object and remove strong refs to them.
Let's draft possible scenarios:
Explicitly keep the reference to the Promise:
const promise = new Promise((resolve, reject) => {...});
In this case we have a strong reference to the Promise and we won't GC it as long as const promise
exists.
π As long as you keep explicit reference to your promise, it won't be GCed
Lose the references to promise, resolve and reject functions:
let promiseWithoutResolve = new Promise((resolve) => {
setTimeout(() => {
console.log("Timeout for the promise that keeps no refs");
}, 100000);
});
finalizationRegistry.register(promiseWithoutResolve, " which keeps no references");
promiseWithoutResolve = null;
For that test we keep neither strong ref to the promise
, nor resolve
function, however, the promise function has quite a long timeout operation.
π When you lose all refs to the resolve
, reject
and instance itself the object is marked to GC and as soon as GC starts it will be collected. (Note: JS GC has several generations and therefore if your promise is in the 3rd generation, it might not be collected).
Cache resolve
and/or reject
method without strong ref to the promise instance itself
In real codebases you can find something similar to:
let resolve;
let promise = new Promise((_resolve) => {
resolve = _resolve;
});
// Let's remove the reference to promise
promise = null;
This code performs 2 things:
1) Removes the strong reference to promise
2) Caches resolve
function outside callback in the promise constructor.
We don't have a direct access to the promise anymore but it's unclear if this promise will be queued for GC or it will stay as long as we keep the link to the resolve
.
To test this behaviour we can draft an experiment:
let promiseWithResolve = new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 100000);
});
finalizationRegistry.register(promiseWithResolve , " which keeps resolve function");
promiseWithResolve = null;
For such an experiment we have an output:
Promise which keeps resolve function. Time taken: 155765.
π The promise will be kept in memory as long as we have the reference to resolve
or reject
callbacks.
π If we have a promise which lives for a long period of time it may get to the older generation, which means it will take even more time between losing all the refs to the promise and getting it GCed.
.then
chaining
Day-to-day use case:
new Promise(() => {}).then(() => {/* Some async code */})
Experiment to test:
let promiseWithThen = new Promise(() => {});
let then = promiseWithThen.then(() => {
console.log("then reached");
});
finalizationRegistry.register(promiseWithThen, " with `then` chain");
finalizationRegistry.register(then, " then callback");
promiseWithThen = then = null;
The output:
Promise with `then` chain. Time taken: 191.
Promise then callback. Time taken: 732.
The original promiseWithThen
will never be resolved but it's chained by following then
operation, which may keep the reference to the original promise. Luckily, then
doesn't prevent promise from being GCed.
π .then
doesn't prevent promise from GC. Only explicit references to promise itself, resolve
and reject
matter.
What would happen if we add .then
to these experiments?
As we found, .then
. doesn't prevent promises from garbage collection. Which means, it shouldn't have any effect.
To prove this we can draft the experiment which is under Then example
in this sandbox: https://codesandbox.io/s/promises-article-first-example-8jfyh?file=/src/index.js
When you run the experiment you will see that then
really has no effect and it doesn't change any behaviour.
Why several .then chains keep promise alive?
Sometimes in the code we have .then chains:
Promise.resolve()
.then(asyncCode1)
.then(asyncCode2)
...
.then(asyncCodeN);
Even though we don't keep reference to the promise, it isn't scheduled to be GC before the chain completes.
The thing is: Promise.resolve()
returns the resolved promise, and the first .then
plans a microtask to run, since the previous promise is resolved. So, we will have a planned scope to execute.
.then
returns a promise which is chained by the following async operation (asyncCode2
, ...). And the value for the async operation is the return value of the previous .then
block (in our case it's the fn asyncCode1
).
π if your .then
chain gets "control" (planned for execution or even started execution), the function scope has the reference to resolve or reject the promise which is returned by .then
and therefore this promise won't be GCed.
To sum up:
Short list of key findings:
π References to: the promise instance
itself, resolve
, reject
keep the promise from being garbage collected.
π .then
doesn't prevent promise from GC.
π if your .then
chain gets "control" (planned for execution or even started execution), the function scope has the reference to resolve or reject the promise which is returned by .then
and therefore this promise won't be GCed.
π If we have a promise which lives for a long period of time it may get to the older generation, which means it will take even more time between losing all the refs to the promise and getting it GCed.
Top comments (0)