Why Your setTimeout Can Leak Gigabytes: JavaScript GC Explained
This article examines how JavaScript's garbage collection works, why large objects allocated in setTimeout callbacks can cause memory leaks, and provides practical code examples and strategies to prevent such leaks in V8-powered environments.
Background
<code>function demo() {
const bigArrayBuffer = new ArrayBuffer(999_000_000);
const id = setTimeout(() => {
console.log(bigArrayBuffer.byteLength);
}, 1000);
return () => clearTimeout(id);
}
globalThis.cancelDemo = demo();
</code>Using the code above, bigArrayBuffer will leak forever because after one second the function that references it is no longer callable, and the returned cancel function does not reference bigArrayBuffer .
After one second, the function that references bigArrayBuffer cannot be called.
The returned cancel function does not reference bigArrayBuffer .
About Garbage Collection
Basic Principles of Garbage Collection
JavaScript's garbage collection (GC) runs automatically; developers do not need to manage memory manually. The engine (e.g., V8) detects objects that are no longer used and frees their memory. Understanding GC algorithms helps optimize memory usage.
JavaScript GC mainly relies on two algorithms: Mark-and-Sweep and Reference Counting .
1. Mark-and-Sweep Algorithm
This is the most common GC algorithm in modern JavaScript engines.
Mark phase : Starting from root objects (usually the global object like window ), traverse all reachable objects and mark them as active.
Clear phase : Scan all objects in memory and free those that were not marked.
Mark-and-Sweep solves circular reference problems because it only cares about reachability, not reference counts.
2. Reference Counting Algorithm
This simpler algorithm is rarely used in modern engines.
Each object has a reference count; the count increments when a reference is added and decrements when removed.
Objects with a count of zero are reclaimed immediately.
Reference counting cannot handle circular references because objects in a cycle never reach a count of zero.
Garbage Collection in the V8 Engine
V8 (used by Chrome and Node.js) employs generational garbage collection, dividing memory into New Space and Old Space.
New Space
Newly allocated objects reside here; the space is small and collected frequently. V8 uses the Scavenge algorithm:
New Space is split into two semi-spaces: From Space (used) and To Space (free).
When From Space fills, live objects are copied to To Space, and From Space is cleared.
The roles then swap.
Old Space
Objects that survive longer are promoted to Old Space, which is larger and collected less often. V8 uses Mark-and-Sweep and Mark-and-Compact:
Mark-and-Sweep : Mark live objects and clear the rest.
Mark-and-Compact : After marking, compact live objects to one end of memory to reduce fragmentation.
Triggers for Garbage Collection
The engine automatically triggers GC under several conditions:
New Space memory reaches a threshold.
Old Space memory reaches a threshold.
Explicit memory‑intensive operations are performed.
Example Code to Avoid Memory Leaks
Below are patterns that help prevent leaks:
<code>// Example 1: Avoid global variables
function createScope() {
let localVariable = 'I am local'; // use local variable
console.log(localVariable);
}
createScope();
// Example 2: Proper use of closures
function createClosure() {
let closureVariable = 'I am a closure variable';
return function() {
console.log(closureVariable);
};
}
let closure = createClosure();
closure(); // call closure
closure = null; // release closure reference
// Example 3: Clear timers
let timerId = setTimeout(() => {
console.log('This is a timer');
}, 1000);
clearTimeout(timerId); // clear timer
// Example 4: Remove event listeners
let element = document.getElementById('myElement');
function handleClick() {
console.log('Element clicked');
}
element.addEventListener('click', handleClick);
element.removeEventListener('click', handleClick); // remove listener
</code>Cause Analysis
Consider the following variations and why they do or do not leak:
<code>function demo() {
const bigArrayBuffer = new ArrayBuffer(999_000_000);
console.log(bigArrayBuffer.byteLength);
}
demo();
</code>After the function runs, bigArrayBuffer is no longer needed and can be reclaimed.
<code>function demo() {
const bigArrayBuffer = new ArrayBuffer(999_000_000);
setTimeout(() => {
console.log(bigArrayBuffer.byteLength);
}, 1000);
}
demo();
</code>Here the engine keeps bigArrayBuffer because the inner function references it, linking it to the scope created by demo() . After one second the inner function is no longer callable, so the scope and the buffer become collectible.
<code>function demo() {
const bigArrayBuffer = new ArrayBuffer(999_000_000);
const id = setTimeout(() => {
console.log('hello');
}, 1000);
return () => clearTimeout(id);
}
globalThis.cancelDemo = demo();
</code>In this case the engine knows it does not need to retain bigArrayBuffer because the returned cancel function never accesses it.
If we modify the code so that the cancel function still holds a reference to the timer, the buffer leaks:
<code>function demo() {
const bigArrayBuffer = new ArrayBuffer(999_000_000);
const id = setTimeout(() => {
console.log(bigArrayBuffer.byteLength);
}, 1000);
return () => clearTimeout(id);
}
globalThis.cancelDemo = demo();
</code>The engine sees bigArrayBuffer is referenced by the inner function, so it keeps the buffer.
After one second the inner function is no longer callable, but the cancel function remains, keeping the scope alive.
Therefore the buffer stays in memory.
Setting globalThis.cancelDemo = null removes the reference, allowing the buffer to be reclaimed:
<code>globalThis.cancelDemo = null;
</code>Alternatively, clearing the timer and nullifying the buffer inside the cancel function also works:
<code>function demo() {
let bigArrayBuffer = new ArrayBuffer(999_000_000);
const id = setTimeout(() => {
console.log(bigArrayBuffer.byteLength);
}, 1000);
return () => {
clearTimeout(id);
bigArrayBuffer = null;
};
}
globalThis.cancelDemo = demo();
</code>Conclusion
If you allocate large objects inside a setTimeout callback and the reference is retained elsewhere, be sure to clear the callback and release the reference to avoid memory leaks.
Code Mala Tang
Read source code together, write articles together, and enjoy spicy hot pot together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.