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 by Lin Clark or 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 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:
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.
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
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
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 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
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.:
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. 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
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 in React projects:
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:
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()
.
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 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).
Top comments (3)
Hi! I'm Surim Son, a front-end developer At Korean FE Article Team.
It was a very good article and it was very helpful.
I am going to translate the article into Korean and post it on Korean FE Article mailing substack(kofearticle.substack.com/), is it okay?
Korean FE Article is an activity that picks good FE articles every week, translates them into Korean, and shares them.
It is not used commercially, and the source must be identified.
Please think about it and reply. I'll wait!
Hi Surim. Sure, you're welcome to translate it.
Really interesting and well explained read. Thank you!