Frontend Engineering
Memory Management and Garbage Collection
Quiz: Find the Leak
advanced13 min read
Can You Spot Every Leak?
You've learned the theory. Time to put it to work. Each snippet below contains a memory leak that would cause real problems in production. For each one, ask yourself:
- What is leaking?
- Why — what's the reference chain from a GC root to the retained object?
- How do you fix it?
Some of these are obvious once you know the patterns. Others are genuinely tricky. All are based on real bugs found in production codebases. Let's see how you do.
Leak 1: The Modal Manager
class ModalManager {
constructor() {
this.modals = [];
}
open(content) {
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = content;
const closeBtn = document.createElement('button');
closeBtn.textContent = 'Close';
closeBtn.addEventListener('click', () => {
modal.remove();
});
modal.appendChild(closeBtn);
document.body.appendChild(modal);
this.modals.push(modal);
}
}
const manager = new ModalManager();
// User opens and closes modals throughout the session
Quiz
The Fix
class ModalManager {
constructor() {
this.modals = [];
}
open(content) {
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = content;
const closeBtn = document.createElement('button');
closeBtn.textContent = 'Close';
const controller = new AbortController();
closeBtn.addEventListener('click', () => {
controller.abort(); // remove listener
modal.remove(); // remove from DOM
const idx = this.modals.indexOf(modal);
if (idx > -1) this.modals.splice(idx, 1); // remove from array
}, { signal: controller.signal });
modal.appendChild(closeBtn);
document.body.appendChild(modal);
this.modals.push(modal);
}
}
Leak 2: The Search Cache
const searchCache = {};
async function search(query) {
if (searchCache[query]) {
return searchCache[query];
}
const results = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then(r => r.json());
searchCache[query] = results;
return results;
}
// User types in a search box with debounce — each unique query is cached
Quiz
The Fix
class LRUSearchCache {
#cache = new Map();
#maxSize;
constructor(maxSize = 50) {
this.#maxSize = maxSize;
}
get(key) {
if (!this.#cache.has(key)) return undefined;
const value = this.#cache.get(key);
// Move to end (most recently used)
this.#cache.delete(key);
this.#cache.set(key, value);
return value;
}
set(key, value) {
if (this.#cache.has(key)) this.#cache.delete(key);
this.#cache.set(key, value);
if (this.#cache.size > this.#maxSize) {
const oldest = this.#cache.keys().next().value;
this.#cache.delete(oldest);
}
}
}
const searchCache = new LRUSearchCache(50);
async function search(query) {
const cached = searchCache.get(query);
if (cached) return cached;
const results = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then(r => r.json());
searchCache.set(query, results);
return results;
}
Leak 3: The Dashboard Widget
function DashboardWidget({ dataSource }) {
const [data, setData] = useState(null);
useEffect(() => {
const ws = new WebSocket(dataSource);
ws.onmessage = (event) => {
const parsed = JSON.parse(event.data);
setData(parsed);
};
ws.onerror = (err) => {
console.error('WebSocket error:', err);
};
}, [dataSource]);
if (!data) return <div>Loading...</div>;
return <div>{data.value}</div>;
}
// Widget is mounted/unmounted as user navigates dashboard tabs
Quiz
The Fix
function DashboardWidget({ dataSource }) {
const [data, setData] = useState(null);
useEffect(() => {
const ws = new WebSocket(dataSource);
ws.onmessage = (event) => {
const parsed = JSON.parse(event.data);
setData(parsed);
};
ws.onerror = (err) => {
console.error('WebSocket error:', err);
};
return () => {
ws.close(); // close connection on unmount
};
}, [dataSource]);
if (!data) return <div>Loading...</div>;
return <div>{data.value}</div>;
}
Leak 4: The Event Logger
class EventLogger {
constructor() {
this.logs = [];
}
init() {
document.addEventListener('click', (e) => {
this.logs.push({
type: 'click',
target: e.target, // stores the DOM element reference
timestamp: Date.now(),
path: e.composedPath() // stores the entire DOM path
});
});
document.addEventListener('keydown', (e) => {
this.logs.push({
type: 'keydown',
key: e.key,
timestamp: Date.now()
});
});
}
}
const logger = new EventLogger();
logger.init();
Quiz
The Fix
class EventLogger {
#logs = [];
#maxEntries = 500;
#controller = new AbortController();
init() {
document.addEventListener('click', (e) => {
this.#addLog({
type: 'click',
targetTag: e.target.tagName, // store tag name, not the element
targetId: e.target.id,
timestamp: Date.now()
// Don't store e.target or e.composedPath() — they retain DOM nodes
});
}, { signal: this.#controller.signal });
document.addEventListener('keydown', (e) => {
this.#addLog({
type: 'keydown',
key: e.key,
timestamp: Date.now()
});
}, { signal: this.#controller.signal });
}
#addLog(entry) {
this.#logs.push(entry);
// Evict oldest entries when over limit
if (this.#logs.length > this.#maxEntries) {
this.#logs.splice(0, this.#logs.length - this.#maxEntries);
}
}
destroy() {
this.#controller.abort();
this.#logs.length = 0;
}
}
Leak 5: The Tooltip with Closure Trap
function createTooltipSystem() {
const allTooltips = [];
return function showTooltip(element, content) {
const bigPrecomputedData = computeAllPositions(document.body);
// bigPrecomputedData is ~2MB of layout calculations
const tooltip = document.createElement('div');
tooltip.className = 'tooltip';
tooltip.textContent = content;
document.body.appendChild(tooltip);
allTooltips.push(tooltip);
function position() {
const rect = element.getBoundingClientRect();
tooltip.style.top = `${rect.bottom + 5}px`;
tooltip.style.left = `${rect.left}px`;
}
element.addEventListener('mouseenter', position);
element.addEventListener('mouseleave', () => {
tooltip.remove();
});
};
}
const showTooltip = createTooltipSystem();
// Called for every element that needs a tooltip
Quiz
The Fix
function createTooltipSystem() {
return function showTooltip(element, content) {
const tooltip = document.createElement('div');
tooltip.className = 'tooltip';
tooltip.textContent = content;
// Compute positions in an isolated function — doesn't pollute closure scope
const pos = computeTooltipPosition(element);
const controller = new AbortController();
element.addEventListener('mouseenter', () => {
document.body.appendChild(tooltip);
tooltip.style.top = `${pos.top}px`;
tooltip.style.left = `${pos.left}px`;
}, { signal: controller.signal });
element.addEventListener('mouseleave', () => {
tooltip.remove();
}, { signal: controller.signal });
// Return cleanup function
return () => controller.abort();
};
}
function computeTooltipPosition(element) {
const allPositions = computeAllPositions(document.body);
// allPositions is only reachable within this function
// It becomes garbage when this function returns
const rect = element.getBoundingClientRect();
return { top: rect.bottom + 5, left: rect.left };
}
Leak 6: The SPA Route Handler
let previousController = null;
function navigateTo(path) {
const container = document.getElementById('app');
container.innerHTML = '';
const controller = new AbortController();
previousController = controller;
fetch(`/api/page/${path}`, { signal: controller.signal })
.then(r => r.json())
.then(data => {
if (controller.signal.aborted) return;
renderPage(container, data, controller);
});
}
function renderPage(container, data, controller) {
// Create a complex page with many event listeners
const scrollHandler = () => updateScrollPosition(data);
const resizeHandler = () => recalculateLayout(data);
const keyHandler = (e) => handleShortcuts(e, data);
window.addEventListener('scroll', scrollHandler, { signal: controller.signal });
window.addEventListener('resize', resizeHandler, { signal: controller.signal });
document.addEventListener('keydown', keyHandler, { signal: controller.signal });
container.innerHTML = buildPageHTML(data);
}
// User navigates between pages in the SPA
Quiz
The Fix
let previousController = null;
function navigateTo(path) {
// Abort previous page's listeners and fetches
if (previousController) {
previousController.abort();
}
const container = document.getElementById('app');
container.innerHTML = '';
const controller = new AbortController();
previousController = controller;
fetch(`/api/page/${path}`, { signal: controller.signal })
.then(r => r.json())
.then(data => {
if (controller.signal.aborted) return;
renderPage(container, data, controller);
})
.catch(e => {
if (e.name !== 'AbortError') throw e;
});
}
Leak 7: The Recursive Component with Ref
function TreeNode({ node, onSelect }) {
const elementRef = useRef(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
const el = elementRef.current;
if (!el) return;
const observer = new MutationObserver((mutations) => {
mutations.forEach(m => {
if (m.type === 'attributes') {
onSelect(node, el.dataset.state);
}
});
});
observer.observe(el, { attributes: true });
}, [node, onSelect]);
return (
<div ref={elementRef} data-state={expanded ? 'open' : 'closed'}>
<span onClick={() => setExpanded(!expanded)}>{node.label}</span>
{expanded && node.children?.map(child => (
<TreeNode key={child.id} node={child} onSelect={onSelect} />
))}
</div>
);
}
Quiz
The Fix
useEffect(() => {
const el = elementRef.current;
if (!el) return;
const observer = new MutationObserver((mutations) => {
mutations.forEach(m => {
if (m.type === 'attributes') {
onSelect(node, el.dataset.state);
}
});
});
observer.observe(el, { attributes: true });
return () => {
observer.disconnect(); // always disconnect observers
};
}, [node, onSelect]);
How Did You Do?
| Leaks Found | Level |
|---|---|
| 7/7 | You catch leaks in code review before they ever reach production. Impressive. |
| 5-6 | Strong foundation. Go back and review the patterns you missed — they'll click fast. |
| 3-4 | Good awareness. Revisit the leak patterns topic and practice with DevTools. |
| 1-2 | No worries — start with the memory leak patterns lesson and work through each example in DevTools. This stuff takes practice. |
Key Rules
- 1Every addEventListener needs a removal path: named function + removeEventListener, or AbortController.
- 2Every useEffect that subscribes must return a cleanup function. No exceptions, no excuses.
- 3Never store DOM element references (e.target, composedPath) in long-lived data structures. Store serializable data instead.
- 4Every in-memory collection (cache, log, history) needs a maximum size and eviction strategy.
- 5Isolate heavy computations from closure scopes. Return only what the closure needs.
- 6When replacing a resource (AbortController, WebSocket, Observer), destroy the old one before creating the new one.