/ JS Checker Fixes / Memory Leaks

How to Fix JavaScript Memory Leaks

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.

1. Confirm and reproduce the leak

Step 1
Memory profile in DevTools
DevTools → Memory tab → "Allocations on timeline" → start recording → perform the user flow several times → stop. Memory chart shows allocations; healthy apps see allocations followed by garbage collection drops. Leaks show stair-step growth that never drops.
Step 2
Identify the leaking flow
Common leak triggers:
  • Navigation between SPA routes
  • Opening and closing modals
  • Infinite scroll lists
  • WebSocket connections
  • Inserting/removing dynamic components

2. Take heap snapshots

Step 1
Three-snapshot technique
  1. Memory tab → "Heap snapshot" → take snapshot 1 (baseline)
  2. Perform the leaking action and undo it (e.g., open modal, close modal)
  3. Take snapshot 2
  4. Repeat the action and undo
  5. Take snapshot 3
  6. Switch view: "Comparison" → comparing snapshot 3 to snapshot 1
Objects that exist in snapshot 3 but not 1 — and grow proportionally to the repetitions — are leaking.
Step 2
Filter for Detached DOM
In the snapshot, filter Class by "Detached". Lists DOM nodes JS still holds references to but aren't in the document. Click a node → "Retainers" panel shows what holds it alive — usually a closure, array, or property.

3. Common leak patterns and fixes

Leak 1: Event listeners not removed

// 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 2: setInterval never cleared

// 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 3: Detached DOM held in arrays

// 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 4: Closures over heavy scope

// 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

4. React-specific patterns

// 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]);
}

5. Use WeakMap and WeakRef

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
}

6. Detect leaks in CI

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.

7. Monitor in production

// 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.

💡 Most SPA memory leaks come from three patterns: missed listener cleanup, missed interval/subscription cleanup, and arrays/maps holding references to deleted DOM. Focus diagnosis on those three first; they cover 90% of cases.

⚙️ Re-run the JS Checker

Verify memory growth has stabilised after fixes.

Run JS Checker →
Related Guides: JS Checker Fixes  ·  Fix Long Tasks  ·  Fix JS Console Errors  ·  JS Checker Guide
💬 Got a problem?