I still remember the first time a button click triggered three different handlers and left me wondering why my app felt “haunted.” It wasn’t a bug in the handlers themselves—it was the DOM doing exactly what it’s designed to do. Event bubbling is the mechanism that makes a single user action ripple up through ancestor elements. Once you truly understand it, you stop fighting the browser and start using it to build cleaner, more maintainable interfaces. In this post, I’ll walk you through how bubbling actually works, how it interacts with capturing, and where it matters most in modern web apps. I’ll show you concrete, runnable examples, explain common mistakes I see in production code, and give you a mental model you can keep in your head without needing a diagram every time you debug a click. If you’ve ever had a modal close unexpectedly, a list item handler fire twice, or a delegated listener behave oddly, you’re in the right place.
The mental model I use: ripples in a pond
When you click a button, that click doesn’t stop at the button. The event starts at the target element, then bubbles up through its ancestors: parent, grandparent, and so on, until it reaches the document root. I like the ripple analogy: the point you touch is the target, but the effect travels outward in expanding rings. In DOM terms, each ancestor gets a chance to respond.
There are actually three phases in the full event flow:
1) Capturing phase: the event travels from the root down to the target
2) Target phase: the event is dispatched at the target
3) Bubbling phase: the event travels back up to the root
Most of the time, we only observe the bubbling phase because that’s the default for event listeners. Unless you set { capture: true }, your handlers are listening during the bubbling phase.
Why does this matter? Because a single click can fire multiple listeners on multiple elements, and the order is predictable. If you understand that order, you can diagnose nearly any “mysterious” UI behavior in seconds.
A runnable example that reveals the flow
Here’s a simple DOM tree with three nested elements. If you click the child, you’ll see the handlers fire from inner to outer, which is bubbling in action.
Event Bubbling Demo
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; padding: 24px; }
.grandparent {
border: 2px solid #c62828;
padding: 24px;
}
.parent {
border: 2px solid #1565c0;
padding: 24px;
}
.child {
border: 2px solid #2e7d32;
padding: 24px;
}
Grandparent
Parent
Child
const grandparent = document.getElementById("grandparent");
const parent = document.getElementById("parent");
const child = document.getElementById("child");
grandparent.addEventListener("click", (e) => {
console.log("Grandparent handler");
});
parent.addEventListener("click", (e) => {
console.log("Parent handler");
});
child.addEventListener("click", (e) => {
console.log("Child handler");
});
Click on “Child,” and you should see:
- Child handler
- Parent handler
- Grandparent handler
That ordering is the first key to mastering bubbling. The event fires on the target, then climbs upward through ancestors in order.
Capturing vs bubbling: when the order flips
Sometimes you want to run code before the target handler. That’s when capturing comes in. When you pass { capture: true }, your handler runs on the way down to the target rather than on the way back up.
parent.addEventListener("click", (e) => {
console.log("Parent capturing handler");
}, { capture: true });
With a capturing listener on the parent, the order becomes:
1) Parent capturing handler
2) Child handler (target)
3) Parent bubbling handler (if you also attached one)
4) Grandparent bubbling handler
I rarely use capture in everyday UI work, but I rely on it for global monitoring and for cases where I need to intercept events early—like before a focus change, or when building drag-and-drop behaviors that need to cancel default actions before they occur.
If you’re debugging an ordering issue, the first thing I check is whether any handler has { capture: true }. That single option flips where the handler lands in the flow.
Why bubbling exists: delegation and scalability
The most practical benefit of bubbling is event delegation. Instead of attaching listeners to every child, you attach one listener to a parent and inspect event.target.
Here’s a real-world pattern: a todo list where items can be added and removed dynamically. Without delegation, you’d have to attach a new listener every time you add a list item. With bubbling, you attach one handler to the list.
- Buy coffee
- Ship release notes
- Schedule onboarding
const list = document.getElementById("todo-list");
list.addEventListener("click", (e) => {
// Only handle clicks on list items
const item = e.target.closest("li");
if (!item || !list.contains(item)) return;
console.log("Clicked item:", item.dataset.id);
});
This is my go-to for any list, table, or menu that changes over time. The handler stays stable, and the DOM can grow or shrink without extra wiring.
Two practical tips I rely on here:
- Use
closest()instead of checkinge.targetdirectly. Clicks can land on nested elements inside a list item. - Always validate that the item is within the container (using
contains) to avoid strange edge cases when events originate from unexpected nodes.
Delegation is not just cleaner; it’s often more performant. You avoid hundreds of listeners and reduce memory overhead. In large UIs, that difference can be noticeable.
The event object: your debugging toolbox
I tell every junior engineer I mentor: stop guessing, inspect the event object. It contains everything you need to understand how an event is flowing.
The properties I use most:
e.target: the element that triggered the evente.currentTarget: the element whose listener is currently runninge.eventPhase: numeric phase (1 capture, 2 target, 3 bubble)e.composedPath(): the full propagation path, especially useful with shadow DOM
A quick debug helper:
function logEvent(e) {
console.log({
target: e.target.tagName,
currentTarget: e.currentTarget.tagName,
phase: e.eventPhase
});
}
child.addEventListener("click", logEvent);
parent.addEventListener("click", logEvent);
grandparent.addEventListener("click", logEvent);
When you see the target stay the same but the currentTarget change, you’re watching bubbling in action. That difference alone explains 80% of the confusion I see in code reviews.
Stopping bubbling: when and how to do it
You can stop bubbling by calling e.stopPropagation() inside a handler. That prevents the event from reaching ancestors.
child.addEventListener("click", (e) => {
e.stopPropagation();
console.log("Child handler only");
});
Be careful with this. I treat stopPropagation like a circuit breaker: powerful, but dangerous if used casually. It can break higher-level logic like global click tracking, modal dismissals, or delegated handlers. If you use it, document why, and consider whether a more targeted condition would work instead.
There’s also e.stopImmediatePropagation(), which not only stops bubbling but also prevents other listeners on the same element from running. I’ve used this when implementing layered security or when two handlers were incompatible and I needed a hard stop. It’s rare, but useful.
Preventing default vs stopping propagation
These are commonly confused, so here’s the clean distinction I always use:
e.preventDefault()stops the browser’s default action (like following a link or submitting a form)e.stopPropagation()stops the event from bubbling to ancestors
They solve different problems. You often need both, but not always.
Example: a link inside a list item. You want the link to behave normally but you don’t want the list item’s click handler to run when the link is clicked. You’d stop propagation but not prevent default.
list.addEventListener("click", (e) => {
const link = e.target.closest("a");
if (link) return; // let the link handle itself
const item = e.target.closest("li");
if (item) console.log("List item clicked");
});
In this case, you don’t even need to stop propagation—just check for a link and exit early. That’s my preferred approach: use logic, not brute force.
Common mistakes I still see in production
These are patterns I actively watch for in reviews. If you recognize them, you can fix subtle bugs before they ship.
1) Checking e.target without accounting for nested elements
If your button contains an icon or a span, the target may be the inner element, not the button itself. Use closest().
2) Attaching listeners to each child instead of delegating
This leads to memory bloat and bugs when children are added dynamically. Delegation is almost always cleaner.
3) Overusing stopPropagation
It breaks invisible contracts. If you must use it, document the reason and consider whether a scoped check would suffice.
4) Assuming event order without knowing capture settings
One capture: true can flip your expectations. I recommend searching for { capture: true } when debugging.
5) Mixing onclick attributes with addEventListener
Inline handlers run at the target and can make event flow harder to reason about. I stick to addEventListener for consistency.
When I use bubbling on purpose
I rely on bubbling for all of these:
- Dynamic lists and tables
- Menus with nested items
- Global click tracking (e.g., analytics)
- Click-to-close overlays
- Single-page apps where DOM updates are frequent
In each case, bubbling lets me attach fewer listeners and keep logic centralized. In modern component frameworks, delegation can even improve performance because it avoids reattaching handlers during re-renders.
When I avoid bubbling on purpose
There are also cases where I intentionally block or ignore bubbling:
- Highly sensitive interactions where parent handlers must not run (e.g., in complex forms)
- Drag-and-drop behaviors that rely on precise target control
- Nested interactive components (like a dropdown inside a clickable card)
In those cases, I use early returns or stopPropagation to keep the event contained. The key is to be deliberate: know whether you want parent handlers to fire and decide accordingly.
Real-world scenario: modal close behavior
A classic bubbling bug: clicking inside a modal closes it because the overlay has a click handler that dismisses the modal.
Here’s the correct pattern I use:
const overlay = document.getElementById("overlay");
const modal = document.getElementById("modal");
overlay.addEventListener("click", () => {
console.log("Close modal");
});
modal.addEventListener("click", (e) => {
e.stopPropagation(); // prevent overlay click
});
This is a valid use of stopPropagation because the modal is a nested interactive region that shouldn’t trigger the overlay’s dismissal. You can also handle this with a guard condition:
overlay.addEventListener("click", (e) => {
if (e.target.closest("#modal")) return; // ignore clicks inside modal
console.log("Close modal");
});
I prefer the guard when I want a single handler and minimal propagation control. I prefer stopPropagation when I want a hard boundary, especially in large layouts where an accidental click inside could still bubble past the overlay.
Shadow DOM and composed paths
If you work with web components, you’ll run into shadow DOM. Events can cross shadow boundaries if they are composed, and the actual propagation path may not be obvious.
Use e.composedPath() to see the full chain:
document.addEventListener("click", (e) => {
console.log(e.composedPath());
});
When I debug componentized UIs, composedPath() is the first tool I reach for. It clarifies which elements participate in propagation and which are hidden behind a shadow boundary.
If you don’t work with shadow DOM, you can ignore this section. But in 2026, with web components more mainstream and design systems growing, it’s worth knowing.
Performance considerations: how much does it cost?
In most apps, bubbling overhead is trivial. However, in massive grids or dashboards, attaching a listener to each cell can add overhead and slow down interactions. Delegation can improve performance because you reduce the total number of active listeners. I’ve seen handler overhead shrink from noticeable “jank” to smooth interactions by moving from per-item handlers to a single delegated handler.
Latency isn’t just about the number of listeners. It’s also about what each handler does. A typical handler should run in a few milliseconds. If it does expensive DOM queries or heavy computation, bubbling can amplify the cost because multiple handlers run for a single click. In that case, optimize the handlers themselves rather than fighting bubbling.
A quick rule of thumb I use:
- If you have hundreds of similar items: delegate
- If you have a handful of unique elements: attach directly
- If you need strict boundaries: consider stopping propagation
Modern patterns: framework integration
Most UI frameworks already rely on event propagation under the hood. Even if you don’t see it, it’s there. The key is knowing how to integrate with it when you drop down to native DOM events.
A few tips I use in modern projects:
- Keep delegated handlers close to the container component
- Use
data-attributes to identify targets reliably - Prefer
pointerdownorpointerupfor nuanced input handling when needed - Avoid mixing framework-managed handlers with manual
addEventListeneron the same node unless you know the order
In 2026, I also see teams using AI-assisted tooling to generate interaction scaffolds. That’s fine, but I always audit the event flow. AI can produce correct-looking code that hides bubbling pitfalls, especially around nested components. I check the event chain manually before signing off.
Traditional vs modern approach to list interactions
Here’s a quick comparison that I use when explaining delegation to teams.
Traditional per-item handlers
—
Attach a listener per item
Must attach listeners on insert
More overhead as list grows
Scattered handlers
When I build new UI, I default to delegation unless the handler is unique per item and truly needs isolated state.
Edge cases that surprise people
These show up in real apps more than you’d think:
- Clicking on text selects a text node, so
e.targetmay not be an element. UsenodeTypechecks orclosest()to normalize. - Disabled form controls don’t fire click events the way you might expect. The event may target a wrapper instead.
- Some events don’t bubble (like
focusandblur), but their counterpartsfocusinandfocusoutdo. If you’re delegating focus events, use the bubbling versions. - If you call
e.stopPropagation()in a capturing listener, the event won’t reach the target at all. That’s a common pitfall when people use capture for global handlers.
When these happen, I open DevTools, log e.target, e.currentTarget, and e.eventPhase, and check composedPath() if shadow DOM is involved. That quick inspection usually explains everything.
A pattern I recommend for large apps
This pattern keeps event logic consistent and debuggable:
1) Delegate at container boundaries (lists, tables, sections)
2) Normalize targets with closest()
3) Use data attributes for intent, not class names
4) Avoid stopPropagation unless you need a hard boundary
5) Keep handlers small, and call separate functions for real work
Example:
- Welcome email
- Monthly summary
- Old campaign
const inbox = document.querySelector("[data-role=‘inbox‘]");
inbox.addEventListener("click", (e) => {
const item = e.target.closest("li");
if (!item || !inbox.contains(item)) return;
const action = item.dataset.action;
const id = item.dataset.id;
if (action === "open") openMessage(id);
if (action === "archive") archiveMessage(id);
});
function openMessage(id) {
console.log("Open", id);
}
function archiveMessage(id) {
console.log("Archive", id);
}
Notice how the handler is thin. The DOM work happens in one place, and business logic is in dedicated functions. That separation makes debugging far easier.
Testing bubbling behavior
You can test event propagation in unit tests by simulating events and asserting handler order. I generally verify these things:
- The correct handler fires for delegated events
- The event does not bubble past a boundary when it shouldn’t
- The target normalization behaves as expected
If you’re using a test runner with DOM simulation, ensure your event creation sets { bubbles: true } when needed. Some synthetic events don’t bubble unless you explicitly request it.
Example with a basic DOM event:
const event = new MouseEvent("click", { bubbles: true });
child.dispatchEvent(event);
Without bubbles: true, your test may pass for the target but fail to test the propagation chain. I’ve seen that bug lead to production issues because the unit tests gave a false sense of safety.
Putting it all together
Event bubbling is not just a curiosity of the DOM; it’s a foundation for how UI interactions scale. It’s why delegation works, why a click can close your modal, and why a tiny change to a handler can affect multiple layers of the page.
If you keep a few core ideas in mind, you’ll be in control:
- The event starts at the target and bubbles up through ancestors
e.targetis where it started;e.currentTargetis where you are- Capturing flips the order and runs on the way down
- Delegation is the modern, scalable approach for dynamic UI
stopPropagationis powerful but should be used sparingly
I’ve built systems where a single delegated handler replaced hundreds of individual listeners, cutting maintenance effort dramatically. I’ve also cleaned up bugs that were purely the result of misunderstood bubbling. Once you see the event flow clearly, those bugs become easy to fix—and even easier to avoid.
If you want a practical next step, pick a UI surface in your app that has many listeners (like a list, menu, or table) and refactor it to a single delegated handler. Log e.target and e.currentTarget for a few clicks and watch how the flow behaves. You’ll internalize bubbling quickly, and your codebase will benefit from the clarity. If you prefer a smaller exercise, add a capturing listener to a parent and watch how the order changes; the first time you see it in action, it sticks.
From there, you’ll start using bubbling as a tool, not a mystery—and that’s when DOM events stop being frustrating and start feeling predictable.


