On JS Closures and Leaks
If you haven’t read (entirely) this Jake’s post you should.
Now hear me out: the moment JS developers would need to track or nullify variables as if they are writing Rust is the moment any reason for scripting to exist is basically dead … so I am not going down that road, I am taking a tangent to this issue.
Avoid closures when not needed
As simple and silly as this might sound, every time you write this:
const outer = { huge: 'reference' };
setTimeout(() => {
console.log(outer);
}, 1000);
you are better off with this:
const outer = { huge: 'reference' };
setTimeout(console.log, 1000, outer);
And every time you write this:
class Counter {
constructor(element) {
this.i = 0;
element.addEventListener('click', () => {
console.log(this.i++);
});
}
}
you are better off with this:
class Counter {
constructor(element) {
this.i = 0;
element.addEventListener('click', this);
}
handleEvent(event) {
console.log(this.i++);
}
}
I wrote like 7 years ago why latter pattern matters, beside this blog post topic, and you should read it if you didn’t know about such pattern.
Track your references with ease
I have been dealing with memory leaks related topics for many years and recently I got that bar rised via WASM related projects where not leaking foreign PLs references is crucial.
Because I wrote tons of libraries to make leaks less possible, I also updated recently the most important one, the one that deals with the FinalizationRegistry, which now exposes a handy utility to track collected references via console.debug
right on your browser’s devtools.
import BUG_GC from 'https://esm.run/gc-hook/track';
// HINT: use a constant so that rollup or bundlers
// can eventually remove all the dead code in production
// when the following constant is `false` instead
const D = true;
// create any reference
let test = { any: 'value' };
// when debugging, pass an object literal to simplify
// naming -> references convention
D&&BUG_GC({ test });
setTimeout(() => { test = null; });
// now press the Collect Garbage button in devtools
// and see the lovely message: **test** collected
The gc-hook module is 100% code covered and it’s been used in production for more than a year to avoid leaks from WASM targeting PLs to JS and vice-versa. Its /track
export is just an utility that uses the module behind the scene and it will show via console.debug
any reference that was collected. You can name references via object literal and read in devtools when these are collected. If you don’t read anything after you pressed the Collect Garbage button in your devtools it simply means that never happened, the end.
P.S. you need to enable verbose / all things in devtools to read console.debug
in there … please double check before thinking your reference actually leaked!
As Summary
Apparently, due performance reasons, JS engines are refusing to fix a bug that is actually haunting the Web when it comes to memory consumption.
There are, however, easy ways to avoid closures leaks, such as:
- avoid closures when not necessary … that includes every
setTimeout
orsetInterval
that is not using extra arguments after the delay. If your counter-argument is that such practice requires extra memory to retain that outer scoped callback in memory try to do the math: is it better to have little extra overhead once or to have that callback overhead (the new closure each time) also leaking forever in your code? - avoid closures to re-assign methods in classes so that these can be used as listeners, there is a
handleEvent
pattern that works fast and better since year 2000 - track references with ease through
gc-hook/track
module if you are worried something won’t get cleaned up - use the React compiler if you are using React as apparently that tries to mitigate the issue too
- be mindfull of the unnecessary closures you create in your code … those are easy to write but apparently extremely difficult to digest behind the scene … nobody wins if each closure results into a leak (luckily, that’s not always the case, you should track that though)
Happy TS or JS coding everyone 👋