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
window→document→<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
<div id="outer">
<div id="middle">
<button id="inner">Click</button>
</div>
</div>instrument all three layers
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:
focusandblur— non-bubbling. Usefocusin/focusoutif you need delegation.mouseenterandmouseleave— non-bubbling. Usemouseover/mouseoutif you need delegation.load,unload,scrollon 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.
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.
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
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
documentfor "click outside to close". Without care, the click inside the modal bubbles up and closes it immediately. Fix: checkevent.target.closest(".modal")before closing, orstopPropagationinside 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
documentfor clicks; a button inside callsstopPropagation"to be safe", and the dropdown never closes.
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.
document.addEventListener("click", (e) => {
console.log(e.composedPath().map((n) => n.nodeName || n.constructor.name));
// ["BUTTON", "DIV", "DIV", "BODY", "HTML", "#document", "Window"]
});