Browser Dom
One Listener, Thousand Rows
Reading level
The table that slows down at 500 rows
You build a data table. 100 rows load fine. At 500 rows, adding new rows takes 300ms. At 1000 rows, clicking a row handler feels laggy. You check the code — you're attaching a click listener to every row as it mounts. That's 1000 DOM event listeners. The fix is one listener on the table instead.
Event delegation is the pattern of attaching one listener to a parent element and using event.target to determine which child was clicked. It works because of event bubbling — events propagate up from the target element to the root. One listener on <table> handles clicks on any <tr>, <td>, or <button> inside it.
Beyond performance: event delegation handles dynamically-added elements automatically. A listener attached to a parent catches events on children that didn't exist when the listener was added. This eliminates the need to re-bind listeners after table rows are added, filtered, sorted, or virtualized.
Event bubbling and closest()
When you click a <button> inside a <tr> inside a <table>, the click event fires on the button, then bubbles up to the tr, then the table, then body, then document. Every element in the chain can listen for it.
// ✅ One listener handles all rows — present and future
table.addEventListener('click', (event) => {
const row = event.target.closest('tr[data-id]');
if (!row) return; // click wasn't on a row
const id = row.dataset.id;
handleRowClick(id);
});
closest() walks up the DOM from the clicked element and returns the first ancestor matching the selector — or null if none found. It's the key to event delegation with nested elements.
Three delegation patterns for different use cases:
// Pattern 1: closest() for nested elements
container.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (btn) handleAction(btn.dataset.action, btn);
});
// Pattern 2: target matching with matches()
container.addEventListener('click', (e) => {
if (e.target.matches('.delete-btn')) deleteRow(e.target);
});
// Pattern 3: stop propagation for specific children
container.addEventListener('click', (e) => {
if (e.target.closest('.no-bubble')) return; // opt-out
handleClick(e);
});
When NOT to use delegation: when you need to listen to non-bubbling events (focus, blur — use focusin/focusout instead), or when the parent element has pointer-events: none, or in virtual DOM frameworks where the framework already delegates (React's synthetic event system delegates to the root — adding native listeners per-item fights the framework).
The table that got slower every sort
Alex built an admin panel with a 1000-row activity log table. Each row had a "View details" button. He attached a click listener to every button in a loop when the table mounted. It worked fine. Then the team added filter and sort controls. Every time you filtered or sorted, the table re-rendered — the old rows were removed from the DOM, new rows were created, and the loop ran again to attach 1000 new listeners. After three filter operations, the page was noticeably sluggish. In Firefox's memory profiler, detached DOM nodes were accumulating — old row elements that had been removed from the DOM but not garbage collected because their listener references kept them alive.
The root cause: 1000 calls to addEventListener on mount, no calls to removeEventListener on unmount/re-render. Each listener holds a reference to its row element. When rows were re-created after sort, the old elements became "detached DOM nodes" — no longer in the tree, but still in memory because the JavaScript listener closure referenced them. This is the classic event listener memory leak pattern, amplified by the re-render cycle.
Memory profiling showed ~4MB of detached DOM nodes after 10 sort operations on a 1000-row table. The leak was proportional: each row's listener held a reference to its <tr> element plus the row's data object. Multiplied by 1000 rows, 10 re-renders, this created 10,000 unreachable but uncollected element references. On Safari iOS (which has a smaller JS heap limit), this was causing tab crashes on the activity log page after extended sessions.
One listener on the table, zero leaks
The fix was a single listener on the <tbody> element instead of one per row. When any row's button is clicked, the event bubbles up to the <tbody> listener. Using event.target.closest('[data-row-id]'), the handler finds which row was clicked and extracts the ID. The loop was deleted. Memory leak gone. Sort and filter re-renders are now instant — rows are created and destroyed freely because there are no per-row listeners to manage.
The migration removed ~40 lines and simplified the render logic:
// Before: listener per row (called on every render)
function attachRowListeners(rows) {
rows.forEach(row => {
row.querySelector('.view-btn').addEventListener('click', () => {
openDetail(row.dataset.rowId);
});
});
}
// After: single delegated listener (set once, never removed)
tbody.addEventListener('click', (event) => {
const row = event.target.closest('tr[data-row-id]');
if (!row) return;
openDetail(row.dataset.rowId);
});
The listener is attached once when the component mounts and never removed — because removing the <tbody> removes the listener automatically. New rows added after sort or filter work without any re-binding because the parent listener catches all clicks regardless of when the child was added.
The memory profile after the fix: zero detached DOM nodes after 50 sort operations. The single <tbody> listener adds ~200 bytes. The 1000-listener approach was adding ~400KB per render cycle. The fix also eliminated a class of bugs where newly-added rows (from "Load more" pagination) had no click handlers until a manual re-bind was called. Delegation handles dynamic children automatically by definition.
Pattern at a glance
Annotated example: addEventListener per row vs single delegated listener
❌ ONE LISTENER PER ROW
// Called for every render — O(n) listeners
const rows = document.querySelectorAll('tr');
rows.forEach(row => {
row.addEventListener('click', () => {
handleRowClick(row.dataset.id);
});
});
// 1000 rows = 1000 addEventListener calls
// Re-render after sort = another 1000 calls
// Old rows: detached but not GC'd (leak)
O(n) listeners — grows with list, leaks on re-render
✅ ONE DELEGATED LISTENER ON PARENT
// Set once — handles all rows, present and future
const tbody = document.querySelector('tbody');
tbody.addEventListener('click', (event) => {
const row = event.target.closest('tr[data-id]');
if (!row) return; // click not on a row
handleRowClick(row.dataset.id);
});
// 1000 rows = 1 addEventListener call
// Re-render after sort = 0 additional calls
// Old rows: GC'd immediately — no leak
O(1) listeners — constant cost regardless of row count
Try it: per-row vs delegated listener
Add 200 rows in "Broken" mode — notice the slowdown. Same action in "Fixed" mode is instant. The fixed version has exactly one listener regardless of row count.
Open DevTools Performance → record → add rows in both modes. The broken version shows layout + listener-attach cost growing linearly. Fixed mode is constant time.
Check DevTools Elements → Event Listeners panel on the table in both modes. Broken: n listeners on n rows. Fixed: 1 listener on the table element.
Showing: Fixed — delegated listener
Event delegation: three patterns and their limits
The core pattern — one listener, closest() to identify the target:
// Attach to parent — catches all child clicks via bubbling
document.querySelector('#list').addEventListener('click', (event) => {
// closest() walks UP the DOM from event.target
// Returns first ancestor matching selector, or null
const item = event.target.closest('[data-item-id]');
if (!item) return; // click was on the list but not on an item
console.log('Clicked item:', item.dataset.itemId);
});
Key distinction: event.target is what was actually clicked (the innermost element); event.currentTarget is where the listener is attached (the parent). You almost always want event.target.closest(), not event.target directly — a click on a <span> inside your <button> will have the <span> as event.target.
Three delegation patterns for different scenarios:
// Pattern 1: closest() — nested elements (most common)
container.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
handleAction(btn.dataset.action);
});
// Pattern 2: matches() — simple flat lists
container.addEventListener('click', (e) => {
if (e.target.matches('.list-item')) {
handleItem(e.target);
}
});
// Pattern 3: data-action dispatch — multi-action parents
container.addEventListener('click', (e) => {
const el = e.target.closest('[data-action]');
if (!el) return;
const actions = {
delete: () => deleteRow(el.closest('tr').dataset.id),
edit: () => openEditor(el.closest('tr').dataset.id),
expand: () => toggleRow(el.closest('tr').dataset.id),
};
actions[el.dataset.action]?.();
});
Implementation notes:
- When delegation fails:
focusandblurdo not bubble — usefocusin/focusoutinstead, which do bubble - stopPropagation: any child that calls
event.stopPropagation()will prevent the event reaching your delegated parent listener — avoid stopPropagation in component libraries - pointer-events: none: if the parent has this CSS, it won't receive clicks — check the computed style if delegation mysteriously stops working
- Shadow DOM: events don't bubble across shadow boundaries by default — delegation only works within the same shadow root
React's synthetic event system already does delegation — all events are delegated to the root container (the React root DOM node), not attached to individual elements. This means adding native addEventListener per item in React fights the framework: you're paying the per-item cost while React's delegation handles the same events at root anyway.
// In React — just use JSX event props
// React delegates onClick to the root automatically
function Row({ id, onSelect }) {
return <tr onClick={() => onSelect(id)}>...</tr>;
}
// Anti-pattern in React: native addEventListener per item
// This adds a SECOND listener alongside React's delegation
React.useEffect(() => {
rowRef.current.addEventListener('click', handler); // don't do this
}, []);
// The only case for native delegation in React: when you need to
// intercept events outside React's tree (portals, third-party widgets)
// or listen on window/document
For virtual lists (react-window, react-virtual): the component only renders visible rows, so per-row listeners are re-attached on every scroll. Delegation on the scroller container is essential here — the container is always mounted, rows come and go. Pass data-id attributes to virtual row elements and delegate from the container.
References
Remember
Key takeaways
-
Attach one listener to the parent, not one listener per child — events bubble up, so the parent hears all clicks from its descendants.event.target.closest(selector) is the delegation primitive — it finds the nearest ancestor matching your selector from the clicked element up.React delegates all events to the root already — adding native addEventListener per item fights the framework. Use React event props and rely on its delegation.
-
Delegation handles dynamically-added elements automatically — no need to re-bind when adding, filtering, or sorting rows.For non-bubbling events (focus, blur): use focusin/focusout instead — they bubble and are delegation-compatible.Memory leak prevention: removing the parent container removes the single listener. Per-row listeners require explicit cleanup on each removed row to avoid leaks.
Keep going
Finish this takeaway, then continue the track — Casey saved your spot locally.
Sign in with email to sync progress across devices (beta).
Inside the Casebook
New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.
No spam. Unsubscribe anytime. Emails sent via Buttondown.
RSS feed