Ok, so there have been a lot of comments on what causes this, which are not correct, and a few that are pretty close. Here is the actual explanation of what is happening and what is causing the leak.<p>For context, I used to be on TC39 (the ecmascript standards committee) and spent many many years working on JSC, and specifically working on the GC and closure modeling.<p>First off: this is not due to eval. In ES3.1 or ES5 (alas I can't recall which) we (tc39) clarified the semantics of eval, to only evaluate in the containing scope if it is called directly - essentially turning it into a pseudo operator (implementations today generally implement a direct eval as `if (target function == real eval function) { do eval } else { call the function }`. Calling eval in any way other that `eval(<expression>)` will not invoke the scope capturing behavior of eval (this is a strict requirement to allow fast access to non-local variables).<p>The function being reported as exhibiting the bad/unexpected behavior in the post is:<p><pre><code> function demo() {
const bigArrayBuffer = new ArrayBuffer(100_000_000);
const id = setTimeout(/* timeout closure */ () => {
console.log(bigArrayBuffer.byteLength);
}, 1000);
return /* cleanup closure */ () => clearTimeout(id);
}
</code></pre>
If we were to follow the spec language fairly explicitly, the behavior of this function is (eliding exact semantics of everything other than creation of function objects and closures)<p><pre><code> 1. enter the function
2. env = create an empty lexical environment object
(I may use "activation" by accident because
that was the spec language when I was first
working on JS engines)
a) set the parent scope of env to the internal scope reference of the
callee (in this case because demo is a global function this will be
the global object)
b) add a property "bigArrayBuffer" to env, setting the value
to undefined
c) add a property "id" to env, setting the value to undefined
3. evaluate `new ArrayBuffer(100_000_000)` and assign the result
to the "bigArrayBuffer" property of env
4. Construct a function object for the timeout closure, and set its
internal scope reference to *env* (i.e. capture the containing
scope)
5. call setTimeout passing the function from (4) and 1000 as,
and assign the result to the "id" property on the env object
6. construct the cleanup closure, and set the internal scope
property to env
</code></pre>
The result of this is that we end up with the following set of objects:<p><pre><code> globalObject = {.....}
demo = Function { @scope: globalObject }
<demo_env> (not directly exposed anywhere) =
LexicalEnvironment { @scope: demo.@scope,
bigArrayBuffer: big array,
id: number
}
<timeout closure> = Function { @scope: demo_env }
<cleanup closure> = Function { @scope: demo_env }
</code></pre>
At which point you can see as long as either closure is live, the reference to bigArrayBuffer is reachable and therefore kept alive.<p>Now, I was confused about this report originally as I know JSC at least does do free var anaylsis (and I can't imagine v8 doesn't, not sure about SM these days) to reduce false captures, because I had not properly read the example code, and was like "why is this being kept alive", but having actually read the code properly and written out the above it's hopefully very obvious to everyone now.<p>The language semantics of JS mean that all closures in a given scope chain share that scope chain, which means if one closure captures a variable, then all closures will end up keeping that capture alive, and there is not a lot the JS engine can do to limit that.<p>There are some steps that could be taken to mitigate or reduce this, but doing that kind of flow analysis can become expensive and a real issue JS engines have is that the overwhelming majority of JS runs a tiny number of times, and is extremely latency sensitive (this is why JSC has put so much effort into parsing + interpreter perf), and any real data flow analysis is too expensive for such code, and by the time code is hot enough to have warranted that kind of analysis the overall program state has got to a point where you cannot retroactively remove closure references, so they remain.<p>Now something that you _could_ try as a developer in this kind of scenario would be to use let, or a scoped let, to reduce the sharing of scopes, e.g.<p><pre><code> function demo() {
let id;
{
let bigArrayBuffer = new ArrayBuffer(100_000_000);
id = setTimeout(/* timeout closure */ () => {
console.log(bigArrayBuffer.byteLength);
}, 1000);
}
return /* cleanup closure */ () => clearTimeout(id);
}
</code></pre>
which might resolve this issue, in this particular kind of case.<p>In principle an engine could introduce logic to try to track exactly how many live closures reference a captured variable, but this is also tricky as you could easily end up with something like:<p><pre><code> function f() {
let x = new GiantObject;
return (a) => {
if (a) return (g) => { g(x) }
return (g) => { g(null); }
}
}
y = f() // y needs to keep x alive
y = y(some value) // you get a new closure which
// may or may not be the one referencing
// x.
</code></pre>
This is something you _could_ support, but there's a lot of complexity to ensuring correct behavior and maintaining performance in all the common cases, and it's possibly just not worth it given the JS capturing model.<p>There are also a few things you could do that would likely be relatively easy/low cost from a JS engine that would remove some cases of excessive capture, but they'd still just be helping super trivial cases like this reduced example code, not necessarily any actual real world examples.