mediumFrontend EngineerFintech
Explain how JavaScript closures work and how they can lead to memory leaks in real-world applications
Posted 18/04/2026
by Mehedy Hasan Ador
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:"
1. What is a closure in JavaScript? How does it work under the hood?
2. Why does the cleanup function NOT free the memory?
3. How would you fix this?
> "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:1. What is a closure in JavaScript? How does it work under the hood?
2. Why does the cleanup function NOT free the memory?
3. 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
SettinginnerHTML = '' removes the DOM elements, but the event listeners still hold references to the callback functions. Those callbacks are closures that capture:1.
tx — the transaction object2.
container — the DOM element referenceEven 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 elementThis 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.---