Event Bubbling vs Capturing in JavaScript, Explained
Event bubbling and capturing are the two directions a DOM event travels. How propagation phases work, when to use each, and how to stop them.
Event bubbling and event capturing are the two directions a DOM event travels through the element tree. When you click a button nested inside a form inside a <main>, the browser doesn’t just notify the button. It walks the event down from window to the button (the capture phase), fires it on the button itself (the target phase), and then walks it back up to window (the bubble phase). Listeners run in bubbling order by default; you opt into capturing with a flag. Knowing which phase your listener lives in explains most “why did this handler fire twice” and “why didn’t my handler fire at all” bugs.
The three phases of event propagation
Every dispatched event that participates in propagation goes through the same sequence:
- Capture phase. The event starts at
window, then visitsdocument,<html>,<body>, and every ancestor of the target in order, moving downward. - Target phase. The event reaches the element that triggered it —
event.target. Listeners registered on the target itself run here. - Bubble phase. The event retraces its path upward, from the target’s parent back to
window.
You can check where you are with event.eventPhase, which returns 1 for capturing, 2 for at-target, and 3 for bubbling. Inside any listener, event.target is the element where the event originated, while event.currentTarget is the element whose listener is running right now. Mixing those two up is one of the most common propagation mistakes.
How to listen in each phase
addEventListener takes an optional third argument. Pass nothing (or false) and the listener runs during the bubble phase. Pass true, or an options object with capture: true, and it runs during the capture phase.
const outer = document.querySelector("#outer");
const inner = document.querySelector("#inner");
outer.addEventListener("click", () => console.log("outer capture"), { capture: true });
outer.addEventListener("click", () => console.log("outer bubble"));
inner.addEventListener("click", () => console.log("inner"));
// Clicking #inner logs:
// outer capture
// inner
// outer bubble
One detail trips people up: when removing a listener, the capture flag must match. removeEventListener("click", fn) will not remove a listener added with { capture: true }. If you use an AbortController and pass its signal in the options object instead, you sidestep that bookkeeping entirely — aborting the signal removes the listener regardless of phase.
Bubbling vs capturing at a glance
| Capturing | Bubbling | |
|---|---|---|
| Direction | window down to target | Target up to window |
| Order | Runs first | Runs last |
| How to opt in | { capture: true } or true | Default |
| Typical use | Intercepting before children react | Delegation, general handling |
| Applies to non-bubbling events | Yes | No |
Why bubbling is the default
Bubbling is what makes event delegation possible. Instead of attaching a click handler to every row in a table with a thousand rows, you attach one handler to the table and inspect event.target to figure out which row was clicked. New rows added later work automatically because the handler lives on a stable ancestor.
table.addEventListener("click", (event) => {
const row = event.target.closest("tr[data-id]");
if (!row || !table.contains(row)) return;
openRecord(row.dataset.id);
});
The closest() call matters. The click target may be a <span> or icon inside the cell, not the row itself, so you walk upward from the target to find the element you actually care about.
When capturing is the right tool
Capturing is less common but genuinely useful in a few situations:
- Intercepting before children see the event. An analytics layer or a “click outside to close” handler can listen in the capture phase on
documentand observe every click before component-level handlers have a chance to stop propagation. - Handling events that don’t bubble.
focus,blur,mouseenter,mouseleave,load, andscrollon elements do not bubble. A capture-phase listener on an ancestor still sees them on the way down. That’s how you can track focus changes inside a container with a single listener — thoughfocusinandfocusout, which do bubble, are usually the cleaner choice for that specific case. - Guarding a region. Modal implementations sometimes capture keyboard events at the container level to manage focus trapping. The native dialog element handles much of this for you now, which is a good reason to prefer it.
Stopping propagation, and why to be careful
Three methods change how an event moves:
event.stopPropagation()halts the event at the current element. Listeners further along the path — deeper in the capture phase or higher in the bubble phase — won’t run. Other listeners on the same element still run.event.stopImmediatePropagation()does the same, and also prevents any remaining listeners on the current element from running.event.preventDefault()is unrelated to propagation. It cancels the browser’s default action (following a link, submitting a form, checking a checkbox) but lets the event keep traveling.
Use stopPropagation() sparingly. It’s tempting as a quick fix for a parent handler that fires when it shouldn’t, but it silently breaks everything above that point: delegated handlers, analytics, “click outside” detection, and third-party widgets that rely on document-level listeners. A better pattern is usually to have the parent handler check event.target and bail out when the click came from somewhere it doesn’t care about.
Propagation and performance
Propagation itself is cheap; the browser walks a path it has already computed. What costs time is the work your listeners do. Delegation reduces memory and setup cost by replacing many listeners with one, which helps on large lists. For scroll and touch events, mark listeners as passive when they never call preventDefault(), so the browser can scroll without waiting for your JavaScript to finish.
It also helps to remember that event handlers are just tasks on the main thread. A slow handler on a bubbling ancestor runs for every matching event inside it, and it competes with rendering the same way any other task does — the event loop doesn’t care which phase scheduled the work.
Custom events and shadow DOM
Events you create with new CustomEvent("name") do not bubble unless you pass { bubbles: true }. If you dispatch a custom event from a component and the parent never hears about it, a missing bubbles flag is the first thing to check.
Shadow DOM adds one more wrinkle. By default, events that originate inside a shadow root are retargeted as they cross the boundary, so outside listeners see the host element as event.target rather than the internal node. Custom events also need composed: true to escape the shadow root at all. Built-in UI events like click are already composed.
The takeaway
DOM events travel down through ancestors in the capture phase, fire on the target, then travel back up in the bubble phase. Listeners default to bubbling, which is what makes delegation work; add { capture: true } when you need to see an event first or handle one that doesn’t bubble. Read event.target for where the event started and event.currentTarget for where your listener sits, and reach for stopPropagation() only when you’re sure nothing upstream depends on the event.
Tagged
Keep reading
Takina · · 4 min read Object.groupBy() in JavaScript, Explained
Object.groupBy() buckets array items by a key you compute, replacing the reduce() boilerplate developers have written for years. How it works.
Takina · · 4 min read Web Locks API: Coordinating Work Across Browser Tabs
The Web Locks API lets JavaScript acquire named locks shared across tabs, so only one tab does work like a token refresh or a write at a time.
Takina · · 4 min read Svelte 5 Runes Explained
Svelte 5 runes like $state and $derived replace the old reactive-assignment magic with explicit function calls that work anywhere in a file.