What do I know about Promises GC (and also WeakRefs and FinalizationRegistry) [en]

Posted: April 10, 2021

You can discuss this topic here: https://t.me/fxnim/27

Promises in 2021? Are you kidding? It was explained gazillion times.

Yes, we all know that promise is a then'able object, which follows a number of rules. Some of them may look odd at first glances, like .catch which returns a resolved promise (in case we don't return an error or rejected Promise) but they are logically explained. You always can refresh your knowledge here: https://promisesaplus.com/

Today, I want to talk about how promises work with GC.

Let's play a game. We have 2 cases.

First case:

let resolve;
let promise = new Promise((_resolve) => { 
  resolve = _resolve;
});
promise = null;

We have a strong reference to resolve and we don't have any references to promise.

The question is will be the promise garbage collected?

And the second case:

new Promise(() => {}).then(() => {... some staff}) 

Will be the promise GCed in this case?

We won't rely only on theory, we can also check it practically.

How weak refs work:

JavaScript works with both strong references and weak references.

Strong references are used in our everyday routine. When we create an array, object, almost anything we use them:

const array = []; // strong reference to array
const obj = {}; // strong reference to object

Pure numbers, strings, and undefined are "the exceptions", cause they are primitive types. We don't talk in reference terms about them.

Weak references are relatively new in javascript. You can check their proposal here: https://tc39.es/proposal-weakrefs/#sec-weak-ref-objects

We can create a WeakRef to the object using WeakRef constructor:

const arrayWeakRef = new WeakRef([]);
const objWeakRef = new WeakRef({});

What is the difference between strong and weak refs in general?

1) We cannot be sure if the object which is referenced by the weak ref exists.

2) We should ask for access before getting the object. In javascript it's deref() method which can return null if the original object was GCed:

An image from Notion

!Important note!: do not play with weak refs in dev tools. Your objects won't be GCed in this point. Instead of this, run jsbin or codesandbox.

I've prepared a simple example, where we create 2 weakRefs:

(async function () {
  let one = new WeakRef({
    field:
      "this object usually will be presented in console, cause GC wouldn't be called"
  });

  // This one may be GCed in really rare cases
  console.log(
    "To access the object inside WeakRef in js you have to call deref:",
    one.deref()
  );

  let two = new WeakRef({
    field: "However, once we create many of object we may have our object GCed"
  });
  for (let i = 0; i < 10; i++) {
    await createLotsOfGarbageAndPause();
  }
  // This one can be either defined or GCed
  console.log(
    "Instead of previous one this weakRef has more changes to be GCed",
    two.deref()
  );
})();

Try it by yourself: https://codesandbox.io/s/promises-article-first-example-8jfyh?file=/src/index.js:499-1280 (1 example)

When we create a first weakRef like this:

  let weakRef = new WeakRef({
    foo: 'bar'
  });

We create an object {foo: 'bar'}, but we don't preserve a strong reference to it. So it means, that the object will be marked for GC and it will be GCed.

We can save a WeakRef to the promise and observe. Another solution is to use FinalizationRegistry

FinalizationRegistry

Since we have a way to check if we still have an Object or it's garbage collected, it's a good idea to have an observer, so that we can:

  1. Check when GC is called
  2. Gather some analytics about user performance (btw, one of the biggest problems in client-side performance in my previous work was a large amount of GCs)

We have such a mechanism. It's called FinalizationRegistry: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry

So we can check when the object is GCed:

(async function () {
  // Create a strong ref to the object
  let strongRef = {
    field:
      "Once this object is garbage collected (finalized), we will see it in the console"
  };
  // We can also check whether is object collected or now when FinalizationRegistry is called
  const weakRef = new WeakRef(strongRef);
  const time = performance.now();
  let collected = false;
  // Create a FinalizationRegistry (observer);
  const registry = new FinalizationRegistry((value) => {
    // weakRef.deref should be undefined here
    console.log(
      `The object with field: "${value}" was just finalized. TimeConsumed: ${(
        performance.now() - time
      ).toFixed()}. WeakRef: ${weakRef.deref()}`
    );
    collected = true;
  });

  // Register our object
  registry.register(strongRef, strongRef.field);

  // remove all StrongRefs from the object
  strongRef = null;

  // Wait!
  while (collected !== true) {
    await createLotsOfGarbageAndPause();
  }
})();

Run this example here: https://codesandbox.io/s/promises-article-first-example-8jfyh?file=/src/index.js:1349-2476 (example 2). You'll get in the console something like that:

An image from Notion

Back to promises

Using this approach, we can check promises. https://codesandbox.io/s/promises-article-first-example-8jfyh?file=/src/index.js:2549-3691 (Example 3)

async () => {
  let promiseWithoutResolve = new Promise((resolve) => {
    setTimeout(() => {
      console.log("here");
    }, 100000);
  });
  let promiseWithResolve = new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, 100000);
  });
  let promiseWithThen = new Promise(() => {});
  let then = promiseWithThen.then(() => {
    console.log("then reached");
  });

  const time = performance.now();  
  const registry = new FinalizationRegistry((value) => {    
    console.log(
      `Promise ${value}. Time taken: ${(performance.now() - time).toFixed()}.`
    );
  });

  // Register our object
  registry.register(promiseWithResolve, " with reference to resolve");
  registry.register(promiseWithoutResolve, " without reference to resolve");
  registry.register(promiseWithThen, " with then subscriber");
  registry.register(then, " then result");

  // remove all StrongRefs
  promiseWithResolve = promiseWithoutResolve = promiseWithThen = then = null;

  // Wait!
  while (true) {
    await createLotsOfGarbageAndPause();
  }
};

As soon as we lose all the references to the Promise objects, promiseWithoutResolve, promiseWithThen, and then will be GCed:

An image from Notion

However, promiseWithResolve isn't GCed, cause we're still keeping the reference to resolve function. reject works as well.

The main reason why it works in such a way is to prevent memory leaks. Let's observe it through the example:

const promise = new Promise(() => {}).then(some code 1).then(some code 2).then(some code 3)

Here we have the reference only to the latest promise. At the time we don't have any refs to:

  • new Promise(() => {})
  • then(some code 1)
  • then(some code 2)

It means we may keep them in the memory as we keep the reference to the promise which is returned by the latest then(some code 3) code block, but we can have gazillion promises in chain therefore it may be crucial for the RAM. Each promise can be fulfilled or rejected if and only if we have the reference to resolve or reject function. Therefore we can easily prevent memory leaks by garbage collecting all the promises where we lost reference to Promise itself, resolve and reject functions.

In our codesandbox you can try to remove then from this line and check it by yourself:

promiseWithResolve = promiseWithoutResolve = promiseWithThen = then = null;
An image from Notion

Okay. It's quite simple with Promise object. But what about .then? We have neither reference to Promise nor resolve \ rejected functions, so how does it work?

.then and GC

.then subscribes to our promise and will be executed when the original promise changes its state. So, we should keep the reference to `.then` in our original (or previous) promise instance. For example https://codesandbox.io/s/promises-article-first-example-8jfyh?file=/src/index.js:3877-5211 (example 4)

  let promiseWithoutResolve = new Promise((resolve) => {
    setTimeout(() => {
      console.log("here");
    }, 100000);
  }).then(() => {
    console.log("then 1 executed");
  });
  let promiseWithResolve = new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, 100000);
  }).then(() => {
    console.log("then 2 executed");
  });
  let keepingTheRefToOriginalPromise = new Promise((resolve) => {
    setTimeout(() => {
      console.log("here");
    }, 100000);
  });
  let then3 = keepingTheRefToOriginalPromise.then(() => {
    console.log("then 3 executed");
  });

  const time = performance.now();
  const registry = new FinalizationRegistry((value) => {
    console.log(
      `Promise ${value}. Time taken: ${(performance.now() - time).toFixed()}.`
    );
  });

  // Register our object
  registry.register(promiseWithResolve, " with reference to resolve");
  registry.register(promiseWithoutResolve, " without reference to resolve");
  registry.register(keepingTheRefToOriginalPromise, " original promise");
  registry.register(
    then3,
    " then, where we have the ref to the original promise"
  );

  // remove all StrongRefs
  promiseWithResolve = promiseWithoutResolve = then3 = null;
  // Wait!
  while (true) {
    await createLotsOfGarbageAndPause();
  }

We have 3 different cases:

  1. When we have neither ref to original promise nor to resolve or reject from the original promise
  2. When we have a reference to resolve function of the original promise
  3. When we have a ref to the original promise

Only in the first case our promises will be garbage collected. So we won't get any memory leaks here. In the second and third examples we still have refs to .then through the original promise.

We can make a simple visualisation of this chains:

An image from Notion

In the second picture we still have a reference between Promise and Then , however Promise is not accessible from our code, so it will be marked to GC.

Wrapping up:

1) To avoid GC we should keep a strong reference to either Promise or resolve \ reject function

2) Promise chains don't prevent browser from collecting your Promises

3) then references are stored in the previous promise registry, so as long as we have any refs to the previous promise we have the ref to the then also.