← Back to question bank
DebuggingMidMedium#1010 · 25m

Debug Closure lifetime and memory

Explain captured variables, stale closures, and long-lived listeners. Then apply it to a realistic product screen where a user action, browser behavior, and rendering timing all matter.

Answer Strategy

Closure-lifetime debugging is the question that exposes whether you can name three bugs at once: a stale closure (handler captured an old value), a leaked listener (resource not cleaned up), and a re-attached listener (effect runs every render). Production symptoms blur together — slow input, ghost shortcuts firing, memory growth — but the diagnostic is one chain.

State the hypothesis before changing code. "I see N event listeners growing each render. Either (a) addEventListener is called outside an effect, (b) the effect has wrong deps and re-fires, or (c) the cleanup never runs because the unmount path is broken." Then verify with a Performance recording or a spy on addEventListener/removeEventListener. The fix is the smallest change that makes the assertion hold.

Volunteer the senior tradeoff. A useRef "latest values" pattern (reads current state from a ref instead of closing over it) keeps the effect attached once but loses dependency tracking. A useCallback + useEffect with the callback as a dep re-attaches on parent re-render but stays explicit. Pick the right tool for the lifetime: register-once + ref for global listeners; redo-on-change + cleanup for prop-driven subscriptions.

Reference Implementation: Broken Vs Fixed Closure Lifetime

Side-by-side: BrokenSearchPanel reproduces all three bugs; FixedSearchPanel attaches once, reads latest values from a ref, and cleans up on unmount.

// Symptom from production: switching tabs in a long-running session balloons
// memory and freezes the UI for ~150ms on every interaction. The hypothesis
// is that the panel attaches a global listener that captures stale state
// and is never removed.

// BROKEN: this version creates a new wrapped handler on every render and
// never removes the previous one. It also captures \`onSearch\` from the
// first render forever, so updates to the parent's handler never apply.
function BrokenSearchPanel({ onSearch }: { onSearch: (query: string) => void }) {
  const [query, setQuery] = React.useState('');

  // BUG #1: window.addEventListener inside render attaches on every render.
  // BUG #2: handler closes over \`query\` from this render closure.
  // BUG #3: there is no removeEventListener, so listeners leak forever.
  window.addEventListener('keydown', (event) => {
    if (event.key === 'Enter') onSearch(query);
  });

  return <input value={query} onChange={(event)=> setQuery(event.target.value)} />;
}

// FIXED: register exactly once via useEffect, depend on the values that
// matter, and return a cleanup that removes the same listener.
export function FixedSearchPanel({ onSearch }: { onSearch: (query: string) => void }) {
  const [query, setQuery] = React.useState('');
  // Use a ref to avoid re-attaching the listener on every keystroke.
  // The listener reads the latest values from the ref instead of capturing
  // them in its closure.
  const latest = React.useRef({ query, onSearch });
  React.useEffect(() => {
    latest.current = { query, onSearch };
  });

  React.useEffect(() => {
    function onKey(event: KeyboardEvent) {
      if (event.key === 'Enter') latest.current.onSearch(latest.current.query);
    }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []); // attach once; cleanup on unmount.

  return (
    <input
      value={query}
      onChange={(event)=> setQuery(event.target.value)}
      aria-label="Search"
    />
  );
}

Testing Strategy

Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.

Reproduce
Spy on addEventListener and removeEventListener. The failing test counts listeners across re-renders; it must report N=1 after the fix.
Patch
Move attach into useEffect with [] deps; return a cleanup that removes the same handler. Use a ref if the handler needs the latest props/state.
Prevent
Add the listener-count test plus a render-count test that fails if the parent forgot to memoize the callback prop. Wire eslint-plugin-react-hooks/exhaustive-deps so missing deps warn at lint time.
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent, cleanup } from '@testing-library/react';

describe('FixedSearchPanel', () => {
  afterEach(() => cleanup());

  it('attaches exactly one global listener regardless of re-render count', () => {
    const addSpy = vi.spyOn(window, 'addEventListener');
    const removeSpy = vi.spyOn(window, 'removeEventListener');

    const { rerender, unmount } = render(<FixedSearchPanel onSearch={()=> {}} />);
    rerender(<FixedSearchPanel onSearch={()=> {}} />);
    rerender(<FixedSearchPanel onSearch={()=> {}} />);

    const adds = addSpy.mock.calls.filter(([type]) => type === 'keydown');
    expect(adds).toHaveLength(1);

    unmount();
    const removes = removeSpy.mock.calls.filter(([type]) => type === 'keydown');
    expect(removes).toHaveLength(1);

    addSpy.mockRestore();
    removeSpy.mockRestore();
  });

  it('uses the latest onSearch even when the parent re-renders with a new function', () => {
    const first = vi.fn();
    const second = vi.fn();
    const { rerender } = render(<FixedSearchPanel onSearch={first} />);
    rerender(<FixedSearchPanel onSearch={second} />);
    fireEvent.keyDown(window, { key: 'Enter' });
    expect(second).toHaveBeenCalledTimes(1);
    expect(first).not.toHaveBeenCalled();
  });
});

Interviewer Signal

Shows whether you understand closure lifetime and memory as an operating model, not as memorized trivia.

Constraints

  • Use one concrete browser or React-facing example.
  • Name the failure mode a production user would notice.
  • Keep the first answer under two minutes before expanding.

Model Answer Shape

  • Start with the rule: captured variables, stale closures, and long-lived listeners.
  • Tie the rule to ownership: what runs in render, what runs after paint, what is external state, and what must be cleaned up.
  • Close with the smallest test, trace, or code review check that would catch the bug.

Tradeoffs

  • A short interview answer is easier to follow, but a senior answer must still name the edge case.
  • Framework vocabulary helps only after the browser or language rule is clear.

Edge Cases

  • Slow devices where timing bugs become visible.
  • Repeated user actions before async work settles.
  • Browser defaults that differ from custom component behavior.

Testing And Proof

  • Unit-test the pure decision when possible.
  • Use an interaction test for focus, keyboard, timing, or cleanup behavior.

Follow-Ups

  • How would this change in a React component?
  • What would you log or profile if this broke in production?