Explain how JavaScript closures work and how they can lead to memory leaks in real-world applications
Question Details
During a technical interview at a fintech company, the interviewer presented the following scenario:
"We have a dashboard that renders hundreds of transaction cards. Each card has an event listener attached. When the user navigates away from the dashboard, we noticed memory wasn't being freed. Here's a simplified version of the code:"
function createTransactionCards(transactions) {
const container = document.getElementById('transactions');
transactions.forEach((tx) => {
const card = document.createElement('div');
card.className = 'transaction-card';
// Each card captures 'tx' and 'container' in closure
card.addEventListener('click', () => {
showDetail(tx, container.dataset.activeTab);
});
container.appendChild(card);
});
}
// Called on route change
function cleanup() {
document.getElementById('transactions').innerHTML = '';
}
Questions:
- What is a closure in JavaScript? How does it work under the hood?
- Why does the cleanup function NOT free the memory?
- How would you fix this?
Suggested Solution
What is a Closure?
A closure is a function that retains access to its lexical scope even when executed outside that scope. In JavaScript, when a function is created, it captures a reference to the variables in its surrounding environment.
function outer() {
let count = 0; // This variable is "closed over"
return function inner() {
count++; // Inner still has access to 'count'
return count;
};
}
const counter = outer();
counter(); // 1
counter(); // 2 — count persists because inner() closes over it
Under the Hood
JavaScript engines maintain a scope chain. Each function object has an internal property ([[Environment]]) that references the variable environment where it was created. As long as the function exists, its entire scope chain is kept alive in memory.
Why the Cleanup Doesn't Free Memory
Setting innerHTML = '' removes the DOM elements, but the event listeners still hold references to the callback functions. Those callbacks are closures that capture:
tx— the transaction objectcontainer— the DOM element reference
Even though the DOM nodes are removed from the document, the closures keep references to these objects alive. The garbage collector cannot free them because there's still a reachable reference chain:
event listener → callback closure → tx object + container element
This creates a detached DOM tree + retained closure data — a classic memory leak.
The Fix
Option 1: Remove event listeners explicitly
const listeners = new Map();
function createTransactionCards(transactions) {
const container = document.getElementById('transactions');
transactions.forEach((tx) => {
const card = document.createElement('div');
card.className = 'transaction-card';
const handler = () => showDetail(tx, container.dataset.activeTab);
card.addEventListener('click', handler);
listeners.set(card, handler); // Store reference
container.appendChild(card);
});
}
function cleanup() {
const container = document.getElementById('transactions');
// Remove listeners first
listeners.forEach((handler, card) => {
card.removeEventListener('click', handler);
});
listeners.clear();
container.innerHTML = '';
}
Option 2: Use AbortController (Modern approach)
const abortController = new AbortController();
function createTransactionCards(transactions) {
const container = document.getElementById('transactions');
transactions.forEach((tx) => {
const card = document.createElement('div');
card.addEventListener('click', () => showDetail(tx, container.dataset.activeTab), {
signal: abortController.signal
});
container.appendChild(card);
});
}
function cleanup() {
abortController.abort(); // Removes ALL listeners at once
document.getElementById('transactions').innerHTML = '';
}
Option 3: Event Delegation (Best practice)
function createTransactionCards(transactions) {
const container = document.getElementById('transactions');
// ONE listener on the container, not on each card
container.addEventListener('click', (e) => {
const card = e.target.closest('.transaction-card');
if (card) {
const txId = card.dataset.txId;
const tx = transactions.find(t => t.id === txId);
if (tx) showDetail(tx, container.dataset.activeTab);
}
});
transactions.forEach((tx) => {
const card = document.createElement('div');
card.className = 'transaction-card';
card.dataset.txId = tx.id;
container.appendChild(card);
});
}
Event delegation is the most scalable approach — one listener handles all cards, and there's no closure capturing individual transaction objects per listener.
Key Takeaways
| Concept | Detail |
|---|---|
| Closure | Function + its captured lexical environment |
| Memory leak cause | Closures retain references to outer variables, preventing GC |
| Detached DOM | Removed nodes still referenced by closures = leak |
| Best fix | Event delegation or AbortController |
| Debugging tool | Chrome DevTools → Memory → Heap Snapshot |