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