The DOM and event handling — bubbling, capturing, and delegation
An event doesn't just fire on the element it happened on — it travels through the DOM tree in a specific, two-phase order, and understanding that travel path is what makes both a confusing double-handler bug and a genuinely useful pattern (delegation) make sense.
3 min read
An event travels through the DOM tree, in two phases
<div id="outer">
<button id="inner">Click me</button>
</div>outer.addEventListener("click", () => console.log("outer"));
inner.addEventListener("click", () => console.log("inner"));
// Clicking the button logs: "inner", then "outer" — NOT just "inner" aloneA click on #inner doesn't only trigger #inner's own listener — the event first travels down from the document root to the target (the capturing phase, listeners for which are rare in practice), then travels back up from the target to the root (the bubbling phase, which is what most listeners actually respond to by default). This is why the outer div's click listener fires too, even though the click physically happened on the button — the event bubbles up through every ancestor, triggering each one's listeners along the way, unless something explicitly stops it.
stopPropagation(): the deliberate way to halt the bubble
inner.addEventListener("click", (e) => {
e.stopPropagation(); // the click STILL happens on inner, but never reaches outer's listener
console.log("inner only");
});
outer.addEventListener("click", () => console.log("this never runs now"));event.stopPropagation() prevents the event from continuing its journey up (or down) the DOM tree past the current listener — a deliberate, explicit call needed to stop the default bubbling behavior, since bubbling happens automatically otherwise. This matters for a real, common bug: a modal's "close on outside click" listener on a page-level element can fire unexpectedly when a click inside the modal bubbles up to that page-level listener, unless the modal's own content stops propagation.
Event delegation: one listener, on a parent, instead of one per child
// Without delegation — a listener has to be added to EVERY item, including ones added later
document.querySelectorAll(".item").forEach((item) => {
item.addEventListener("click", handleClick); // items added AFTER this runs get NO listener at all
});
// With delegation — ONE listener on the parent, works for items added at ANY time
document.querySelector(".list").addEventListener("click", (e) => {
if (e.target.matches(".item")) handleClick(e); // relies on bubbling to catch clicks from ANY child
});Because a click on a child bubbles up to its parent, a single listener on the parent can catch clicks from every current and future child, by checking event.target (the element actually clicked) inside the one handler — a direct, practical application of bubbling, not just a trivia fact about it. This pattern (event delegation) is genuinely useful specifically for dynamic lists where items get added or removed after page load: without it, every newly-added item would need its own listener attached manually, which is easy to forget and a real, common source of "why doesn't clicking this newly-added button do anything" bugs.
preventDefault(): stopping the browser's own default behavior, unrelated to propagation
form.addEventListener("submit", (e) => {
e.preventDefault(); // stops the browser's default full-page-reload form submission
// ...handle the submission with fetch() instead, without a page navigation
});
link.addEventListener("click", (e) => {
e.preventDefault(); // stops the browser from navigating to the link's href
});preventDefault() and stopPropagation() are frequently confused but solve genuinely different problems: preventDefault() cancels the browser's own built-in behavior for that event type (a form submitting and reloading the page, a link navigating, a checkbox toggling) — it has nothing to do with whether the event continues bubbling to ancestor listeners, which is stopPropagation()'s separate job. A handler can call either, both, or neither, depending on what's actually needed.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Why does clicking a button inside a div trigger both the button's AND the div's click listeners?
2. How does event delegation take advantage of bubbling?
3. What's the actual difference between `preventDefault()` and `stopPropagation()`?