Skip to the content.

Event bubbling and capturing: the two trips every click takes

Event bubbling and capturing: the two trips every click takes

When you click a button inside a card inside a list, the browser does not fire one listener. The event makes two trips: a capturing phase down from the document to the target, then a bubbling phase back up from the target to the document. Almost every listener you write runs on the way up — which is why bubbling feels like “the default” — but both phases are always happening, and knowing which one your handler runs in is what makes delegation and stopPropagation behave instead of feeling like magic.

The journey of one click

Here is the path a click on the innermost element takes through three nested nodes. Watch the dot: it travels down through the capture phase, hits the target, then travels back up through the bubble phase.

DOM event propagation phases A click event travels down from document to the target during the capture phase, then back up to document during the bubble phase. document <ul> list <li> → <button> (target) capture ↓ bubble ↑
One click, two trips: down through capture, then up through bubble. Most listeners fire on the up-trip.

The two phases in code

addEventListener registers on the bubbling phase by default. Pass { capture: true } and the same handler runs on the way down instead:

const list = document.querySelector("ul");

// Bubbling (default): fires on the way UP, innermost first.
list.addEventListener("click", () => console.log("ul: bubble"));

// Capturing: fires on the way DOWN, outermost first.
list.addEventListener("click", () => console.log("ul: capture"), { capture: true });

For a click on a <button> deep inside that <ul>, the capture listeners fire outermost-first, then the target’s own listeners, then the bubble listeners fire innermost-first back up to the root. So the order above is ul: capture first (going down) and ul: bubble last (coming back up), even though both are on the same element. You almost always want bubbling; capturing is for the rare case where an ancestor must see the event before the target does.

Delegation rides the bubble

The reason bubbling matters day to day is event delegation: one listener on a container instead of one per child. It works precisely because events bubble up to the container, so a single handler can serve thousands of rows — and keep working when you add or remove children, because the listener lives on the parent:

// One listener for the whole list — not one per <li>.
list.addEventListener("click", (event) => {
  const item = event.target.closest("li");
  if (!item || !list.contains(item)) return;      // ignore clicks in the gaps
  console.log("clicked item", item.dataset.id);   // event.target is what was clicked
});

event.target is the element that was actually clicked (the deep one), while event.currentTarget is the element the listener is attached to (the list). That distinction is the whole trick of delegation: the listener is on the parent, but you read the target to find which child. For a dynamic list this is not an optimization, it is the correct default.

stopPropagation and its real cost

event.stopPropagation() halts the trip — the event stops travelling, so ancestors never see it. It is tempting for “this click shouldn’t close the menu,” but it is a blunt instrument that breaks things far away:

// Tempting, and a footgun:
menuButton.addEventListener("click", (event) => {
  event.stopPropagation();   // now a delegated handler on document never fires —
  toggleMenu();              // analytics, "click outside to close", all silently break
});

// Usually better: let it bubble, and have the ancestor check the target.
document.addEventListener("click", (event) => {
  if (!menu.contains(event.target)) closeMenu();   // "click outside" without stopping anything
});

Prefer checking event.target in the ancestor over stopping the event at the child. Reserve stopPropagation for genuinely isolated widgets, and know that stopImmediatePropagation also blocks other listeners on the same element.

Not every event bubbles

A subtlety that trips people up: some events do not bubble at all. focus and blur fire only on the target, which is why the bubbling cousins focusin and focusout exist for catching focus changes on a container. mouseenter/mouseleave do not bubble (their mouseover/mouseout counterparts do). If a delegated handler mysteriously never fires, the first thing to check is whether the event you are listening for bubbles — because delegation depends entirely on the bubble phase, a non-bubbling event never reaches the container listener. The capture phase is your escape hatch here: registering with { capture: true } catches even non-bubbling events on the way down, since capturing visits every ancestor regardless.

The model to keep is the diagram above: down (capture), hit the target, back up (bubble). Delegation lives on the bubble; stopPropagation cuts the trip short and can break things far away; and a handful of events skip the bubble entirely. The delegation exercise and the propagation quiz drill exactly these phases.