Skip to content

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:

  1. What is leaking?
  2. Why — what's the reference chain from a GC root to the retained object?
  3. 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
What leaks in the Modal Manager code?

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
What's the memory problem with this search cache?

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
What leaks when DashboardWidget unmounts?

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
This code has two distinct memory problems. Which answer identifies both?

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
Why does this tooltip system leak significantly more memory than expected?

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
This code attempts to prevent leaks with AbortController. Where does it fail?

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
What leaks in the TreeNode component?

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 FoundLevel
7/7You catch leaks in code review before they ever reach production. Impressive.
5-6Strong foundation. Go back and review the patterns you missed — they'll click fast.
3-4Good awareness. Revisit the leak patterns topic and practice with DevTools.
1-2No worries — start with the memory leak patterns lesson and work through each example in DevTools. This stuff takes practice.
Key Rules
  1. 1Every addEventListener needs a removal path: named function + removeEventListener, or AbortController.
  2. 2Every useEffect that subscribes must return a cleanup function. No exceptions, no excuses.
  3. 3Never store DOM element references (e.target, composedPath) in long-lived data structures. Store serializable data instead.
  4. 4Every in-memory collection (cache, log, history) needs a maximum size and eviction strategy.
  5. 5Isolate heavy computations from closure scopes. Return only what the closure needs.
  6. 6When replacing a resource (AbortController, WebSocket, Observer), destroy the old one before creating the new one.