Experiments with the JavaScript Garbage Collector
Tags: javascript, performance, webdev, debugging
Published: 2023-02-23 17:39:47

Memory leaks in web applications are widespread and notoriously difficult to debug. If we want to avoid them, it helps to understand how the garbage collector decides what objects can and cannot be collected. In this article we'll take a look at a few scenarios where its behavior might surprise you.

If you're unfamiliar with the basics of garbage collection, a good starting point would be [A Crash Course in Memory Management](https://hacks.mozilla.org/2017/06/a-crash-course-in-memory-management/) by Lin Clark or [Memory Management](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management) on MDN. Consider reading one of those before continuing.

## Detecting Object Disposal

Recently I've learned that JavaScript provides a class called [FinalizationRegistry](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry) that allows you to programmatically detect when an object is garbage-collected. It's available in all major web browsers and Node.js.

A basic usage example:

```js
const registry = new FinalizationRegistry(message => console.log(message));

function example() {
    const x = {};
    registry.register(x, 'x has been collected');
}

example();

// Some time later: "x has been collected"
```

When the `example()` function returns, the object referenced by `x` is no longer reachable and can be disposed of.

Most likely, though, it won't be disposed immediately. The engine can decide to handle more important tasks first, or to wait for more objects to become unreachable and then dispose of them in bulk. But you can force garbage collection by clicking the little trash icon in the DevTools ➵ Memory tab. Node.js doesn't have a trash icon, but it provides a global `gc()` function when launched with the `--expose-gc` flag.

![DevTools Memory Tab](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l23os4ss0asqatwrmvq2.png)

With `FinalizationRegistry` in my bag of tools, I decided to examine a few scenarios where I wasn't sure how the garbage collector was going to behave. I encourage you to look at the examples below and make your own predictions about how they're going to behave. 

## Example 1: Nested Objects

```js
const registry = new FinalizationRegistry(message => console.log(message));

function example() {
    const x = {};
    const y = {};
    const z = { x, y };

    registry.register(x, 'x has been collected');
    registry.register(y, 'y has been collected');
    registry.register(z, 'z has been collected');

    globalThis.temp = x;
}

example();
```

Here, even though the variable `x` no longer exists after the `example()` function has returned, the object referenced by `x` is still being held by the `globalThis.temp` variable. `z` and `y` on the other hand can no longer be reached from the global object or the execution stack, and will be collected. If we now run `globalThis.temp = undefined`, the object previously known as `x` will be collected as well. No surprises here.

## Example 2: Closures

```js
const registry = new FinalizationRegistry(message => console.log(message));

function example() {
    const x = {};
    const y = {};
    const z = { x, y };

    registry.register(x, 'x has been collected');
    registry.register(y, 'y has been collected');
    registry.register(z, 'z has been collected');

    globalThis.temp = () => z.x;
}

example();
```

In this example we can still reach `x` by calling `globalThis.temp()`. We can no longer reach `z` or `y`. But what's this, despite no longer being reachable, `z` and `y` are not getting collected.

A possible theory is that since `z.x` is a property lookup, the engine doesn't really know if it can replace the lookup with a direct reference to `x`. For example, what if `x` is a getter. So the engine is forced to keep the reference to `z`, and consequently to `y`. To test this theory, let's modify the example: `globalThis.temp = () => { z; };`. Now there's clearly no way to reach `z`, but it's still not getting collected.

What I think is happening is that the garbage collector only pays attention to the fact that `z` [**is in the lexical scope**](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures#lexical_scoping) of the closure assigned to `temp`, and doesn't look any further than that. Traversing the entire object graph and marking objects that are still "alive" is a performance-critical operation that needs to be fast. Even though the garbage collector could theoretically figure out that `z` is not used, that would be expensive. And not particularly useful, since your code doesn't typically contain variables that are just *chilling* in there.

## Example 3: Eval

```js
const registry = new FinalizationRegistry(message => console.log(message));

function example() {
    const x = {};

    registry.register(x, 'x has been collected');

    globalThis.temp = (string) => eval(string);
}

example();
```

Here we can still reach `x` from the global scope by calling `temp('x')`. The engine cannot safely collect any objects within the lexical scope of `eval`. And it doesn't even try to analyze what arguments the eval receives. Even something innocent like `globalThis.temp = () => eval(1)` would prevent garbage collection.

What if eval is hiding behind an alias, e.g. `globalThis.exec = eval`? Or what if it's used without being ever mentioned explicitly? E.g.:

```js
console.log.constructor('alert(1)')(); // opens an alert box
```

Does it mean that every function call is a suspect, and nothing ever can be safely collected? Fortunately, no. JavaScript makes a distinction between [direct and indirect eval](https://2ality.com/2014/01/eval.html). Only when you directly call `eval(string)` it will execute the code in the current lexical scope. But anything even a tiny bit less direct, such as `eval?.(string)`, will execute the code in the global scope, and it won't have access to the enclosing function's variables.

## Example 4: DOM Elements

```js
const registry = new FinalizationRegistry(message => console.log(message));

function example() {
    const x = document.createElement('div');
    const y = document.createElement('div');
    const z = document.createElement('div');

    z.append(x);
    z.append(y);

    registry.register(x, 'x has been collected');
    registry.register(y, 'y has been collected');
    registry.register(z, 'z has been collected');

    globalThis.temp = x;
}

example();
```

This example is somewhat similar to the first one, but it uses DOM elements instead of plain objects. Unlike plain objects, DOM elements have links to their parents and siblings. You can reach `z` through `temp.parentElement`, and `y` through `temp.nextSibling`. So all three elements will stay alive.

Now if we execute `temp.remove()`, `y` and `z` will be collected because `x` has been detached from its parent. But `x` will not be collected because it's still referenced by `temp`.

## Example 5: Promises
> Warning: this example is a more complex one, showcasing a scenario involving asynchronous operations and promises. Feel free to skip it, and jump to the summary below.

What happens to promises that are never resolved or rejected? Do they keep floating in memory with the entire chain of `.then`'s attached to them?

As a realistic example, here's a common [anti-pattern](https://reactjs.org/blog/2015/12/16/ismounted-antipattern.html) in React projects:

```js
function MyComponent() {
    const isMounted = useIsMounted();
    const [status, setStatus] = useState('');

    useEffect(async () => {
        await asyncOperation();
        if (isMounted()) {
            setStatus('Great success');
        }
    }, []);

    return <div>{status}</div>;
}
```

If `asyncOperation()` never settles, what's going to happen to the effect function? Will it keep waiting for the promise even after the component has unmounted? Will it keep `isMounted` and `setStatus` alive?

Let's reduce this example to a more basic form that doesn't require React:

```js
const registry = new FinalizationRegistry(message => console.log(message));

function asyncOperation() {
    return new Promise((resolve, reject) => {
        /* never settles */
    });
}

function example() {
    const x = {};
    registry.register(x, 'x has been collected');
    asyncOperation().then(() => console.log(x));
}

example();
```

Previously we saw that the garbage collector doesn't try to perform any kind of sophisticated analysis, and merely follows pointers from object to object to determine their "liveness". So it might come as a surprise that in this case `x` is going to be collected!

Let's take a look at how this example might look when something is still holding a reference to the Promise `resolve`. In a real-world scenario this could be `setTimeout()` or `fetch()`.

```js
const registry = new FinalizationRegistry(message => console.log(message));

function asyncOperation() {
    return new Promise((resolve) => {
        globalThis.temp = resolve;
    });
}

function example() {
    const x = {};
    registry.register(x, 'x has been collected');
    asyncOperation().then(() => console.log(x));
}

example();
```

Here `globalThis` keeps `temp` alive, which keeps `resolve` alive, which keeps `.then(...)` callback alive, which keeps `x` alive. As soon as we execute `globalThis.temp = undefined`, `x` can be collected. By the way, saving a reference to the promise itself wouldn't prevent `x` from being collected.

Going back to the React example: if something is still holding a reference to the Promise `resolve`, the effect and everything in its lexical scope will stay alive even after the component has unmounted. It will be collected when the promise settles, or when the garbage collector can no longer trace the path to the `resolve` and `reject` of the promise.

## In conclusion

In this article we've taken a look at `FinalizationRegistry` and how it can be used to detect when objects are collected. We also saw that sometimes the garbage collector is unable to reclaim memory even when it would be safe to do so. Which is why it's helpful to be aware of what it can and cannot do.

It's worth noting that different JavaScript engines and even different versions of the same engine can have wildly different implementations of a garbage collector, and externally observable differences between those.

In fact, the [ECMAScript specification](https://tc39.es/ecma262/#sec-managing-memory) doesn't even require implementations to _have_ a garbage collector, let alone prescribe a certain behavior.

However, all of the examples above were verified to work the same in V8 (Chrome), JavaScriptCore (Safari), and Gecko (Firefox).