Event Bubbling Explained
How events travel up the DOM tree, and how capturing, bubbling, and delegation actually work.
2 min read
When an event fires on an element, it doesn't just run listeners on that one element — it travels through the DOM tree in a predictable path, moving through ancestors on the way down and back up again. Understanding that path explains a lot of behavior that otherwise looks like a bug.
The three phases
Every event goes through up to three phases:
- Capturing — from
windowdown to the target element, top to bottom. - Target — the event reaches the actual element it happened on.
- Bubbling — back up from the target to
window, bottom to top.
By default, addEventListener listens during the bubbling phase:
<div id="outer">
<button id="inner">Click me</button>
</div>document.getElementById("outer").addEventListener("click", () => {
console.log("Outer div handler");
});
document.getElementById("inner").addEventListener("click", () => {
console.log("Inner button handler");
});Clicking the button logs both handlers, in this order:
Inner button handler
Outer div handler
The click starts at the actual target (the button), fires its handler, then bubbles up through each ancestor, firing any click listeners along the way — all the way up to document and window unless something stops it.
Listening during capturing instead
Passing true (or { capture: true }) as a third argument switches a listener to the capturing phase, so it fires on the way down instead:
outer.addEventListener("click", () => console.log("Outer -- capturing"), true);With that, the outer handler fires before the inner one. Capturing is rarely needed in everyday code, but it exists precisely because bubbling alone can't intercept an event before it reaches its target.
Stopping propagation
inner.addEventListener("click", (event) => {
event.stopPropagation();
console.log("Inner only -- this won't bubble to outer");
});stopPropagation() halts the event at whatever phase it's currently in — call it during bubbling and ancestor listeners never run for that event.
Event delegation: bubbling as a feature
Because events bubble, you can attach one listener to a parent and inspect event.target to figure out which child was actually interacted with, instead of attaching a separate listener to every child:
document.querySelector("#todo-list").addEventListener("click", (event) => {
if (event.target.matches(".delete-btn")) {
event.target.closest("li").remove();
}
});This single listener handles clicks on delete buttons for every <li> in the list — including ones added to the DOM after this listener was attached, since the listener lives on the parent, not on each individual child. This pattern, called event delegation, is significantly more efficient than adding a listener to every item, and is the standard approach for dynamic lists.
With events and the DOM covered, the course moves into asynchronous JavaScript — how code deals with things that take time, like network requests.