Back to Browser Internals
Browser Internals
medium
mid

How does event delegation work in the DOM, and why is it efficient?

Delegation: one listener on a common ancestor handles events for many children. Works because events bubble up. Pros: fewer listeners (memory, setup cost), works for dynamically added children. Pattern: `element.addEventListener('click', e => { const item = e.target.closest('[data-id]'); if (!item) return; … })`.

3 min read·~6 min to think through

Closely related to [[event-delegation-and-bubbling]] which has the deeper material.

How it works

Events bubble from the deepest target up the DOM. Instead of attaching a listener to every <li>, attach one to <ul> and identify the actual target:

html
<ul id="list">
  <li data-id="1">A</li>
  <li data-id="2">B</li>
  <li data-id="3">C</li>
</ul>

<script>
document.getElementById("list").addEventListener("click", (e) => {
  const li = e.target.closest("li[data-id]");
  if (!li) return;
  handleItem(li.dataset.id);
});
</script>

e.target.closest(selector) walks up from the actual click target to find a matching ancestor.

Why efficient

  1. Memory — one listener vs N. For lists of thousands, N adds up.
  2. Setup time — attaching N listeners on render is slow; attaching one is O(1).
  3. Dynamic children — new items added later work automatically (no need to re-bind).

Common gotchas

  • Icon inside buttone.target is the icon. Use closest("button") to find the actual button.
  • stopPropagation in a child — kills delegation up the chain. Avoid unless necessary.
  • Non-bubbling eventsfocus, blur, mouseenter, mouseleave don't bubble. Use focusin, focusout, mouseover, mouseout for delegation.

When delegation isn't the right tool

  • A small fixed set of elements — direct listeners are clearer.
  • Need event-specific behavior the ancestor doesn't know about.
  • Performance isn't actually a concern (most apps).

React's delegation

React 17+ delegates all events to the root container. onClick={fn} doesn't bind a real DOM listener per element; the root's listener dispatches. Same idea, baked into the framework.

Practical example: tabbed UI

js
tabs.addEventListener("click", (e) => {
  const tab = e.target.closest("[role=tab]");
  if (!tab) return;
  selectTab(tab.dataset.target);
});

One listener, works for tabs added later, ARIA-compatible.

Interview framing

"Events bubble from the deepest target up the DOM. Delegation puts one listener on a common ancestor and uses e.target.closest(selector) to identify the actual hit. It's efficient on three counts: less memory, faster setup, and it auto-handles dynamically added children. The classic gotcha is the icon-inside-button — e.target is the icon, not the button, so always use closest. focus/blur don't bubble; use focusin/focusout instead. React 17+ uses this pattern internally — every onClick delegates to the app root."

Follow-up questions

  • When is delegation NOT the right choice?
  • How does React's event system relate?
  • What's the difference between target and currentTarget?

Common mistakes

  • Reading e.target instead of using closest.
  • Stopping propagation and breaking delegation.
  • Trying to delegate non-bubbling events.

Performance considerations

  • Single listener O(1) setup. closest() is O(depth) per event — negligible for normal trees.

Edge cases

  • stopPropagation breaks the chain.
  • Composed paths in shadow DOM.
  • React synthetic vs native event ordering.

Real-world examples

  • jQuery .on('click', selector, fn), React's root listener, command palettes, virtualized lists.

Senior engineer discussion

Seniors know the gotchas (closest, non-bubbling events, stopPropagation hazards) and recognize React's delegation as the same pattern at framework level.

Related questions