DEV Community

Nik
Nik

Posted on

JS Promises #3: Garbage collection and memory leaks

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

  1. This codesandbox has all the examples for promises and then chains. Open sandbox and reload it. You can run Promise example first and then example after.

  2. In the end of the article you will find key findings from the article.


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.

We will use 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.

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.

Let's draft possible scenarios:

Explicitly keep the reference to the Promise:

const promise = new Promise((resolve, reject) => {...});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

For such an experiment we have an output:

Promise  which keeps resolve function. Time taken: 155765. 
Enter fullscreen mode Exit fullscreen mode

📝 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 */}) 
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

The output:

Promise  with `then` chain. Time taken: 191. 
Promise  then callback. Time taken: 732. 
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)