Bubbling & Capturing
When you click a button nested inside several containers, the event does not fire on that button alone — it travels through the DOM tree. This journey is called event propagation, and it happens in two directions: down from the document to the target (capturing), then back up to the document (bubbling). Understanding this flow is the key to predicting which handlers run, in what order, and how to stop them.
The three phases of propagation
Every event that hits the DOM moves through three distinct phases:
- Capture phase — the event descends from the
windowdown through each ancestor toward the target element. - Target phase — the event reaches the element the user actually interacted with.
- Bubble phase — the event climbs back up from the target through every ancestor to the
window.
The Event.eventPhase property reports which phase is currently active (1 = capturing, 2 = target, 3 = bubbling).
| CAPTURE PHASE ^ BUBBLE PHASE
| (top -> down) | (bottom -> up)
v |
+-----------------------------------------------+
| document |
| +---------------------------------------+ |
| | <div id="grandparent"> | |
| | +-------------------------------+ | |
| | | <div id="parent"> | | |
| | | +---------------------+ | | |
| | | | <button> TARGET | | | |
| | | +---------------------+ | | |
| | +-------------------------------+ | |
| +---------------------------------------+ |
+-----------------------------------------------+
Listening in the bubble phase (the default)
By default, addEventListener registers a handler for the bubble phase. Because bubbling runs from the target outward, the innermost handler fires first.
<!DOCTYPE html>
<html>
<head>
<style>
div { padding: 24px; border: 2px solid #4f46e5; border-radius: 8px; }
#grandparent { background: #eef2ff; }
#parent { background: #e0e7ff; }
button { padding: 8px 16px; font-size: 14px; cursor: pointer; }
#log { margin-top: 12px; font-family: monospace; white-space: pre-line; }
</style>
</head>
<body>
<div id="grandparent">grandparent
<div id="parent">parent
<button id="child">Click me</button>
</div>
</div>
<div id="log"></div>
<script>
const log = document.getElementById("log");
const print = (msg) => { log.textContent += msg + "\n"; };
["grandparent", "parent", "child"].forEach((id) => {
document.getElementById(id).addEventListener("click", () => {
print(`bubble: ${id}`);
});
});
</script>
</body>
</html>
Clicking the button logs the order from target outward:
Output:
bubble: child
bubble: parent
bubble: grandparent
Listening in the capture phase
To run a handler during the descent, pass true (or { capture: true }) as the third argument to addEventListener. Capture handlers fire from the outermost ancestor inward — the opposite order of bubbling.
parent.addEventListener("click", handler, true);
// or, more explicitly:
parent.addEventListener("click", handler, { capture: true });
If you attach both capture and bubble listeners across the tree, the complete order for a single click is: capture from top to target, then bubble from target back to top.
const ids = ["grandparent", "parent", "child"];
ids.forEach((id) => {
const el = document.getElementById(id);
el.addEventListener("click", () => console.log(`capture: ${id}`), true);
el.addEventListener("click", () => console.log(`bubble: ${id}`));
});
Output:
capture: grandparent
capture: parent
capture: child
bubble: child
bubble: parent
bubble: grandparent
Note that the listeners on the target element itself run in registration order, not strictly capture-then-bubble — at the target there is no meaningful direction, so both
captureandbubblehandlers fire as they were added.
Stopping propagation
Sometimes you want an event to stop traveling once a handler has dealt with it. Two methods control this:
event.stopPropagation()— prevents the event from continuing to the next element in the propagation path. Other handlers on the current element still run.event.stopImmediatePropagation()— stops the event from reaching the next element and prevents any remaining handlers on the current element from running.
child.addEventListener("click", (event) => {
event.stopPropagation(); // parent and grandparent will NOT see this click
console.log("handled at child, propagation stopped");
});
| Method | Stops travel to other elements | Stops other handlers on same element |
|---|---|---|
stopPropagation() | Yes | No |
stopImmediatePropagation() | Yes | Yes |
Stopping propagation breaks event delegation and global listeners (analytics, “click outside to close” menus). Reach for it only when you genuinely own the entire interaction; otherwise prefer checking
event.targetto filter what a handler responds to.
A note on target vs. currentTarget
Inside any handler, event.target is the element where the event originated (the button you clicked), while event.currentTarget is the element whose listener is currently running. During bubbling these differ, which is exactly what makes delegation possible.
<!DOCTYPE html>
<html>
<head>
<style>
#toolbar { display: flex; gap: 8px; padding: 16px; background: #f1f5f9; border-radius: 8px; }
button { padding: 8px 14px; cursor: pointer; }
#out { margin-top: 12px; font-family: monospace; }
</style>
</head>
<body>
<div id="toolbar">
<button data-action="save">Save</button>
<button data-action="copy">Copy</button>
<button data-action="delete">Delete</button>
</div>
<div id="out">Click a button…</div>
<script>
const toolbar = document.getElementById("toolbar");
const out = document.getElementById("out");
// One listener on the parent handles every button (delegation).
toolbar.addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button) return;
out.textContent =
`target = <button "${button.dataset.action}">, ` +
`currentTarget = <div id="${event.currentTarget.id}">`;
});
</script>
</body>
</html>
Events that do not bubble
Most UI events (click, keydown, input) bubble, but some do not — focus, blur, mouseenter, and mouseleave among them. For these you can either use the capture phase or rely on their bubbling equivalents (focusin/focusout bubble; mouseover/mouseout bubble). The read-only event.bubbles property tells you whether a given event participates in the bubble phase.
Best practices
- Default to the bubble phase; reach for
{ capture: true }only when you specifically need to intercept events before descendants handle them. - Prefer filtering on
event.targetoverstopPropagation()so you do not silently break delegation or document-level listeners elsewhere on the page. - Use
event.currentTarget(notthisin arrow callbacks) to reference the element you attached the listener to. - Remember
focus/blurdo not bubble — usefocusin/focusoutwhen you need delegation for focus events. - Reserve
stopImmediatePropagation()for cases where you must guarantee no sibling handler on the same element runs, and document why. - Pass the same options object to
removeEventListener(includingcapture) that you used to add the listener, or it will not be removed.