A grid re-renders 5,000 rows on every data update and attaches a click listener to each row afterwards; clicks feel sluggish and the tab's memory grows over time. How does moving to one delegated listener on the container change that, and what does it not fix?
answer
- wiring scales with the data
- one registration for the page's lifetime
- per-row closures capture per-row nodes
- references leak, not listeners as such
- the render is still the big cost
basics
~20 sA single listener on the container replaces 5,000 registrations and 5,000 closures per render with one, so the post-render wiring pass disappears. It does not make building 5,000 rows cheaper — that render, not the listeners, is usually the dominant cost.
solid answer
~50 sPer-row listeners make the wiring pass scale with the data: after every update you walk 5,000 elements, allocate 5,000 closures — each capturing its row — and make 5,000 registration calls, all synchronously on the main thread. Delegation makes that pass constant: one closure, one registration, done at setup and never repeated. The memory side is usually indirect rather than a listener leak — a handler that captures its row keeps that row alive for as long as anything holds the handler, so any cache or array that outlives a render now pins whole subtrees. What delegation does not fix is the render itself: creating and inserting 5,000 nodes stays the biggest cost, and if the grid is genuinely that large the real answer is rendering fewer rows. Delegation also shifts a little cost to dispatch time — a short ancestor walk per click, which is negligible.
code
javascript · 23 linesconst rows = Array.from({ length: 5000 }, (_, i) => {
const row = document.createElement('div');
row.dataset.id = String(i);
row.textContent = `row ${i}`;
return row;
});
console.time('per-row wiring');
for (const row of rows) {
row.addEventListener('click', () => console.log('picked', row.dataset.id));
}
console.timeEnd('per-row wiring');
const container = document.createElement('div');
container.append(...rows);
document.body.append(container);
console.time('delegated wiring');
container.addEventListener('click', (event) => {
const row = event.target.closest('[data-id]');
if (row && container.contains(row)) console.log('picked', row.dataset.id);
});
console.timeEnd('delegated wiring');go deeper
Be able to say that per-row wiring repeats for every row on every render while a delegated listener is registered once, and that fewer registrations means less work after each update.
Quantify it: 5,000 closures plus 5,000 registration calls per render versus one, all synchronous on the main thread, and note the small dispatch-time cost delegation adds in exchange.
Get the memory story right — references retain, listeners alone do not — name the capture chain that makes per-row handlers pin detached subtrees, and insist on a heap snapshot or a timed wiring pass before rewriting.
Separate the wiring problem from the scale problem: delegation bounds wiring cost, but 5,000 live rows is an architecture question answered by windowing and recycling. Decide which one the team spends its budget on, and on the evidence.
## Count the work that actually happens Per render with per-row listeners, on the main thread, synchronously: - allocate one function object per row (5,000 closures, each capturing at least the row and probably surrounding state); - make one `addEventListener` call per row, each of which mutates the listener bookkeeping for that node; - and, if the code is careful, one removal call per discarded row on the way out. With delegation, all of that becomes: one closure, one registration, once. Not "cheaper per row" — *absent*. That is the shape of the win: it changes the wiring pass from O(rows) per update to O(1) for the page's lifetime. ```js // per-row: runs 5,000 times after every render for (const row of rows) row.addEventListener('click', () => select(row.dataset.id)); // delegated: runs once, ever container.addEventListener('click', (event) => { const row = event.target.closest('[data-id]'); if (row && container.contains(row)) select(row.dataset.id); }); ``` ## Where the memory actually goes Be precise here, because the sloppy version of this answer ("listeners leak") is wrong. When a node is discarded and nothing else references it, the node, its listeners and the closures those listeners captured all become unreachable together and are collected. Listeners on removed nodes are not, by themselves, a leak. The growth comes from the *references*, and per-row handlers manufacture them. A handler that closes over its row keeps that row reachable from the handler; the handler is reachable from the node; and if any long-lived structure — a `Map` of id to row, an array of "previously rendered" nodes, a memoisation cache, a closure captured by a timer — still points at any of it, the whole detached subtree stays in memory. Multiply by 5,000 rows per update and the arithmetic gets ugly fast. Delegation removes the entire category: there are no per-row functions, so no per-row capture, so a stale reference to a row retains only that row. The other classic version is registering on a long-lived ancestor inside a per-render function — the listener count on `document` climbs by one every render, and every one of them still runs on every click. That is a genuine leak, and it is one delegation causes when it is set up in the wrong place: wire the container once at construction, never inside the render path. ## What delegation does not fix **The render.** Creating 5,000 elements, setting their content and inserting them dominates everything the listeners did. If the update loop is slow, measure before attributing it to wiring — the wiring pass is usually a fraction of the node construction. **The row count.** 5,000 live rows cost memory and layout regardless of how they are wired. The structural answer at that size is to stop rendering them all: render a window of what is visible and recycle. Delegation composes beautifully with that, because recycled rows need no rewiring, but it is not a substitute for it. **Per-event work.** If the handler itself does something expensive, doing it from one listener instead of 5,000 changes nothing about the cost of the work. ## The cost delegation adds Dispatch moves from "the right handler is already attached" to "find which row this belongs to", i.e. a `closest()` walk of a few levels per interaction. That is nanoseconds against a click, and it happens once per interaction rather than once per row, so the trade is overwhelmingly favourable. It is worth naming in an interview anyway, because it shows you know the pattern is a trade and not free. The other cost is indirection: behaviour no longer sits next to the element it affects, and the dispatch selector becomes an implicit contract with the markup. For a handful of static controls that is a real readability loss and per-element handlers are the better engineering choice. At 5,000 rows it is not a close call. ## How you would verify it Time the wiring pass in isolation (`console.time` around the loop that attaches) and compare it with the render around it — that tells you immediately whether listeners are the problem or a distraction. For the memory claim, take a heap snapshot after several updates and look for detached elements retained by function objects; if per-row handlers are the cause, the retaining path names them. Reach that evidence before rewriting, so the change is justified rather than folklore.
- Is "listeners on removed nodes leak memory" an accurate diagnosis?No, and stating it that way is a red flag. A discarded node, its listeners and their captured closures all become unreachable together once nothing else references the node. The leak comes from something still holding a reference — a cache, an array of rendered nodes, a timer's closure — and per-row handlers make each such reference retain far more than it otherwise would.
- Where would delegation itself introduce the leak you are trying to avoid?When the delegated listener is registered inside the render function instead of once at construction. Each render adds another listener to the same long-lived container, so the count climbs forever and every one of them runs on every interaction. Wire the container once, outside the update path.
- At 5,000 rows, what would you do beyond switching to delegation?Stop rendering 5,000 rows. Render only the visible window and recycle nodes as the user scrolls, so element count stays bounded regardless of data size. Delegation composes well with that — recycled rows need no rewiring — but node construction and layout, not listener registration, are what actually dominate at that scale.
saying these in an interview costs you the question
- Claims listeners on removed nodes always leak memory
- Says delegation makes rendering 5,000 rows faster
- Registers the delegated listener inside the render function
- Ignores that per-row closures capture their row
- Rewrites for performance without measuring the wiring pass