- Published on
Subtle memory issues in JavaScript thanks to closures
I found out that setTimeout can accept more than two arguments
- Authors
- Name
- Nico Prananta
- Follow me on Bluesky
Recently, I came across an interesting blog post by Jake Archibald titled Garbage collection and closures where he shows how mishandling closures can lead to memory leaks. The demo
function in the following snippet is the problematic code:
<!doctype html>
<html>
<head>
<title>Memory Leak Test</title>
</head>
<body>
<button id="runDemo">Run Demo</button>
<button id="cancelDemo">Cancel Demo</button>
<button id="forceGC">Force Garbage Collection</button>
<script>
function demo() {
const bigArrayBuffer = new ArrayBuffer(100_000_000)
const id = setTimeout(() => {
console.log(bigArrayBuffer.byteLength)
}, 1000)
return () => clearTimeout(id)
}
let cancelDemo
document.getElementById('runDemo').addEventListener('click', () => {
cancelDemo = demo()
})
document.getElementById('cancelDemo').addEventListener('click', () => {
if (cancelDemo) {
cancelDemo()
cancelDemo = null
}
})
document.getElementById('forceGC').addEventListener('click', () => {
if (window.gc) {
window.gc()
}
})
</script>
</body>
</html>
At first glance, this code seems straightforward:
- It creates a large
ArrayBuffer
of 100 million bytes (about 100 MB). - It sets a timeout to log the size of this buffer after 1 second.
- The cancellation function is persisted and assigned to
cancelDemo
.
The Subtle Issue
While this code doesn't have a traditional memory leak, it does have a subtle memory retention issue. The large ArrayBuffer
is kept in memory by the closure created in the setTimeout
callback. This happens because the returned function of demo
is still around after the demo()
function has finished executing.
The ArrayBuffer
cannot be garbage collected until the cancelDemo
function is set to null
(line 30). Forcing the garbage collection by clicking the "Force Garbage Collection" button also will not help in this case because the memory is still retained by the closure.
Analyzing with Chrome DevTools
To visualize this issue, I used Chrome DevTools' Memory tab to take heap snapshots. Here's what I observed:
- Before running the
demo()
function, the baseline memory usage was relatively low. - After running
demo()
, there was a significant increase in memory usage, with a largeArrayBuffer
object present in the heap. - This increased memory usage persisted, even though the
demo()
function had already returned.
Image
The Fix
One of the ways to fix this led me to find out that setTimeout
can accept more than two arguments. We can modify the code to pass the ArrayBuffer
as the third argument to setTimeout
. This prevents the creation of a closure that captures the entire ArrayBuffer
. Here's the improved version:
function demo() {
const bigArrayBuffer = new ArrayBuffer(100_000_000)
const id = setTimeout(
(buffer) => {
console.log(buffer.byteLength)
},
1000,
bigArrayBuffer
)
return () => clearTimeout(id)
}
let cancelDemo = demo()
Analyzing the Fix
After implementing this fix, I took new heap snapshots. As shown in the following image, the memory usage never stayed high after the demo()
function had finished executing. Instead, it decreased to a more manageable level.
Image
Update: Check out the next post which explains why this fix works.
By the way, I'm making a book about Pull Requests Best Practices. Check it out!