JavaScriptEvent Bubbling & Capturing

Event Bubbling

When you click an element, the event does not only fire on that element. It travels through the DOM tree in two passes — first down from window to the target, then back up. This two-phase propagation is what makes event delegation possible, and it is the source of most "why is this listener firing on the wrong thing?" mysteries. Once you can picture the phases, the rest is mechanical.

The three phases

The DOM spec defines three phases for every event that travels through the tree:

  • Capture phase — the event walks down from windowdocument<html><body> → ... → the target. Listeners registered with { capture: true } fire here, in document order.

  • Target phase — the event reaches the actual target. Listeners on the target itself fire (regardless of capture/bubble setting).

  • Bubble phase — the event walks back up the same path to window. Plain listeners (the default) fire here, in reverse document order.

The bubble phase is what most code relies on. Capture phase is rare but powerful.

Seeing the path

HTML
<div id="outer">
  <div id="middle">
    <button id="inner">Click</button>
  </div>
</div>

instrument all three layers

JS
for (const id of ["outer", "middle", "inner"]) {
  const el = document.getElementById(id);

  el.addEventListener("click", () => console.log("bubble:", id));
  el.addEventListener("click", () => console.log("capture:", id), { capture: true });
}
// Clicking the button prints:
capture: outer
capture: middle
capture: inner
bubble:  inner
bubble:  middle
bubble:  outer

Notice the symmetry: capture goes parent-first, bubble goes child-first. The target itself sees both kinds of listeners in registration order.

Not every event bubbles

Most do, but a handful do not. The classic non-bubbling events are:

  • focus and blur — non-bubbling. Use focusin / focusout if you need delegation.

  • mouseenter and mouseleave — non-bubbling. Use mouseover / mouseout if you need delegation.

  • load, unload, scroll on an element — non-bubbling.

Whether an event bubbles is in its bubbles property and on the MDN page for the event type.

stopPropagation

Calling event.stopPropagation() ends the journey. Listeners on the current node still run, but the event will not travel further along its path — neither up the rest of the bubble nor down the rest of the capture.

JS
document.getElementById("inner").addEventListener("click", (e) => {
  e.stopPropagation();
  console.log("stopped here");
});

document.getElementById("outer").addEventListener("click", () => {
  console.log("outer never sees this");
});
stopImmediatePropagation

stopImmediatePropagation() is stricter: it also prevents other listeners on the same element from running.

JS
btn.addEventListener("click", (e) => {
  e.stopImmediatePropagation();
  console.log("first");
});

btn.addEventListener("click", () => {
  console.log("second — never runs");
});
When bubbling helps

Bubbling is the engine behind event delegation — listening on a single parent for events that happen anywhere inside it. It is more efficient than wiring up a listener per child, and it works for elements added later. We cover this pattern in detail on its own page.

one listener, many buttons

JS
document.querySelector(".toolbar").addEventListener("click", (e) => {
  const btn = e.target.closest("button[data-action]");
  if (!btn) return;
  console.log("action:", btn.dataset.action);
});
When bubbling hurts

Three common pitfalls:

  • A modal listens on document for "click outside to close". Without care, the click inside the modal bubbles up and closes it immediately. Fix: check event.target.closest(".modal") before closing, or stopPropagation inside the modal.

  • A child element handles a click; an ancestor also listens for clicks and does something different. Both run unless you stop propagation.

  • A delegated dropdown listens on document for clicks; a button inside calls stopPropagation "to be safe", and the dropdown never closes.

Reach for stopPropagation sparingly
Stopping propagation hides events from code that may have legitimate reasons to know about them — analytics, focus trapping, "click outside" handlers, frameworks. Prefer to handle the case explicitly (`e.target.closest(...)`) instead of cutting the event short.
event.composedPath()

If you want to see the exact list of nodes the event will travel through, call event.composedPath(). It returns an array starting at the target and ending at window.

JS
document.addEventListener("click", (e) => {
  console.log(e.composedPath().map((n) => n.nodeName || n.constructor.name));
  // ["BUTTON", "DIV", "DIV", "BODY", "HTML", "#document", "Window"]
});
Mental model
The event is a ball. The DOM is a slide that goes down then loops back up. Listeners are bells along the slide. Capture listeners ring on the way down, bubble listeners ring on the way up. `stopPropagation` is a gate that ends the ride.