JavaScript has garbage collection, but it can't free objects you still reference. Memory leaks happen when references keep accumulating: event listeners not removed, intervals not cleared, detached DOM held in arrays, closures keeping scopes alive. In SPAs that don't reload on navigation, leaks compound — every route change leaves more orphan objects until the tab crashes. This guide covers diagnosis with heap snapshots and the common leak patterns to fix. For related fixes, see the JS Checker Fixes index.
// Leak: listener references the element forever
class Modal {
constructor() {
this.el = document.createElement('div');
document.body.addEventListener('keydown', this.handleKey);
// No cleanup — handleKey holds 'this' forever
}
handleKey = (e) => { /* ... */ };
}
// Fix: store reference, remove on destroy
class Modal {
constructor() {
this.el = document.createElement('div');
this.handleKey = (e) => { /* ... */ };
document.body.addEventListener('keydown', this.handleKey);
}
destroy() {
document.body.removeEventListener('keydown', this.handleKey);
this.el.remove();
}
}
// Leak: interval runs forever, retains reference to callback closure
function startPolling() {
setInterval(() => {
fetch('/api/status').then(updateUI);
}, 5000);
}
// Fix: save the id, allow clearing
let pollingId;
function startPolling() {
pollingId = setInterval(() => {
fetch('/api/status').then(updateUI);
}, 5000);
}
function stopPolling() {
clearInterval(pollingId);
pollingId = null;
}
// Leak: array keeps removed DOM nodes alive
const cache = [];
function addCard(data) {
const card = document.createElement('div');
cache.push(card); // never removed from cache
return card;
}
// Fix: clean up references when DOM is removed
function removeCard(card) {
const idx = cache.indexOf(card);
if (idx > -1) cache.splice(idx, 1);
card.remove();
}
// Leak: setInterval callback captures 'data' forever
function processLater(data) {
setInterval(() => {
// uses something else, but data is still in scope
console.log('tick');
}, 1000);
// Even though data isn't used in the callback, it's in scope
// and the engine may retain it.
}
// Fix: scope only what's needed
function processLater() {
setInterval(() => {
console.log('tick');
}, 1000);
}
// Process data separately, let it be GC'd
// Leak: useEffect missing cleanup
function ChatRoom({ roomId }) {
useEffect(() => {
const socket = new WebSocket(`wss://chat/${roomId}`);
socket.onmessage = handleMessage;
// No cleanup — socket stays open
}, [roomId]);
}
// Fix: return cleanup function
function ChatRoom({ roomId }) {
useEffect(() => {
const socket = new WebSocket(`wss://chat/${roomId}`);
socket.onmessage = handleMessage;
return () => socket.close(); // runs on unmount and before next effect
}, [roomId]);
}
// Leak: stale state from async work after unmount
function Profile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser); // setUser may fire after unmount
}, [userId]);
}
// Fix: AbortController
function Profile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
return () => controller.abort();
}, [userId]);
}
For caches or auxiliary data tied to DOM nodes or objects, use WeakMap so entries can be garbage-collected when keys are.
// Leak: Map retains keys even after they're removed elsewhere
const cardMeta = new Map();
function addMeta(card, meta) {
cardMeta.set(card, meta); // card never freed even if removed from DOM
}
// Fix: WeakMap allows GC of unused entries
const cardMeta = new WeakMap();
function addMeta(card, meta) {
cardMeta.set(card, meta); // freed when card has no other references
}
Tools like memlab automate leak detection in CI:
# Install
npm install -g memlab
# Define a scenario file: scenario.js
module.exports = {
url: () => 'https://yoursite.com',
action: async (page) => {
await page.click('.open-modal');
await page.click('.close-modal');
},
back: async (page) => {
// return to baseline state
}
};
# Run
memlab run --scenario scenario.js
Memlab opens the page, runs your scenario, compares heap snapshots, reports leaked objects with retainer trace.
// performance.memory (Chrome-only, deprecated, but useful)
if ('memory' in performance) {
setInterval(() => {
const used = performance.memory.usedJSHeapSize;
if (used > 100_000_000) { // 100MB threshold
// Report to monitoring
Sentry.captureMessage(`High memory: ${used}`);
}
}, 60_000);
}
Modern alternative: performance.measureUserAgentSpecificMemory() — more accurate but requires cross-origin isolation. Sentry and similar have memory-tracking integrations.