Community Article

Understanding Event Propagation: Capturing, Bubbling, and More

The three-phase event model in the DOM, why bubbling is the default for historical reasons, when capturing pays off, and how event delegation falls out of the same mechanic.

Understanding Event Propagation: Capturing, Bubbling, and More

The three-phase event model in the DOM, why bubbling is the default for historical reasons, when capturing pays off, and how event delegation falls out of the same mechanic.

js-event-delegation
html-events
js-dom
interview-prep
fundamentals
hannahchakraborty

By @hannahchakraborty

February 23, 2026

·

Updated May 18, 2026

862 views

25

4.4 (13)

button.addEventListener('click', handleClick, true);

That third argument is the entire reason this article exists. Most engineers I work with have written addEventListener thousands of times and never passed it. They have also, almost without exception, hit a bug where a click registered in two places when they only wanted one, or where stopping a parent's handler somehow killed a child's, or where a delegated handler on document ran before the local one they expected. The fix is always the same: understand which phase the listener is in, and which phase the dispatcher is currently traversing. Three phases, two flags, one method, and the whole topic stops being mysterious.

The opinion I want to defend here is that capturing is not exotic and bubbling is not the default for any deep reason. Both phases run on every event. Listeners pick which phase they live in. Once you internalise the three-phase model, event delegation, the React synthetic-event quirks, the Shadow DOM retargeting rules, and passive: true listeners all stop feeling like separate puzzles.

The three-phase model

When the user clicks a deeply nested element, the browser does not just fire a click event on that element. It runs a full traversal of the DOM tree from window down to the target and back up. That traversal has three named phases.

Capturing phase walks down the tree. The browser starts at window, then document, then <html>, then each ancestor of the click target in order, ending one node above the target. At each node it fires every listener registered for that phase.

Target phase fires on the target element itself. Listeners registered for either phase run here, in the order they were added.

Bubbling phase walks back up. The browser visits the same ancestors in reverse, ending at window. At each node it fires every bubbling-phase listener.

DOM event traversal
window
  document
    html
      body
        section.card             <-- bubbling
          div.row                <-- bubbling
            button#delete-3      <-- TARGET
          div.row                <-- capturing
        section.card             <-- capturing
      body
    html
  document
window

Read the diagram top-to-bottom for capturing, then the target line, then bottom-to-top for bubbling. The third argument to addEventListener is which side of the slash you live on:

target.addEventListener('click', fn, true);   // capturing
target.addEventListener('click', fn, false);  // bubbling (default)
target.addEventListener('click', fn);          // bubbling (default)
target.addEventListener('click', fn, { capture: true }); // explicit option form

The default is bubbling, which is why most code only sees half the picture. The browser is always running both phases; only the listeners differ.

Why the default is bubbling, not capturing

Capturing is older than the modern web. Netscape 4 had a capture-only model; IE had a bubble-only model. When the W3C standardized events in DOM Level 2, both phases survived as a compromise. Bubbling became the default because that is what most code written against IE expected, and changing the default would have broken every page on the open web.

There is no semantic argument that bubbling is "right". It is a historical accident with billions of pages of inertia behind it. That accident matters because it shapes which patterns are idiomatic. Event delegation, the most useful application of propagation, works in either phase, but everyone writes it as a bubbling listener on a parent because that is what happens by default.

The two methods that interrupt the traversal

event.stopPropagation() tells the dispatcher to skip every subsequent node in the traversal, but it does not stop sibling listeners on the current node. If three handlers are registered on the same element for the same event and the second one calls stopPropagation, the third one still runs. Only the walk to the next node is suppressed.

event.stopImmediatePropagation() is the heavier hammer. It stops the rest of the listeners on the current node, plus the rest of the traversal. Use this when you have multiple competing handlers on the same element and the first one to claim the event should preempt the others.

function inner(e) {
    e.stopPropagation();
    console.log('inner');
}
function alsoInner(e) {
    console.log('alsoInner');   // this still runs after stopPropagation
}
function outer(e) {
    console.log('outer');       // this does NOT run
}

child.addEventListener('click', inner);
child.addEventListener('click', alsoInner);
parent.addEventListener('click', outer);
// Click the child: logs "inner", "alsoInner". outer is suppressed.

The third method, event.preventDefault(), is unrelated to propagation. It cancels the browser's built-in behavior for the event (form submission, link navigation, scroll on touchmove) but the event still propagates through the DOM. I bring it up only because every junior I have onboarded confused the three at least once.

Event delegation: one listener for many children

The most useful, most underused application of bubbling is event delegation. Instead of attaching a click handler to every row in a long table, attach a single handler to the table and inspect event.target to find which row was clicked.

const list = document.querySelector('#todo-list');
list.addEventListener('click', (e) => {
    const li = e.target.closest('li.todo-item');
    if (!li) return;                       // click was outside any row
    if (e.target.matches('.delete-btn')) {
        deleteTodo(li.dataset.id);
    } else if (e.target.matches('.edit-btn')) {
        editTodo(li.dataset.id);
    }
});

Three wins over per-row listeners. First, you do not have to attach (and detach) listeners as rows are added or removed; the parent listener catches all of them, including rows that did not exist when the page loaded. Second, the memory footprint is fixed regardless of list length. Third, the callback closure is shared, which avoids accidentally capturing per-row state.

The closest and matches calls are doing the real work. event.target is the deepest element under the cursor (could be a <span> inside the button), so you walk up from there until you find the element whose data you actually want, or you bail out if the click was on whitespace.

I default to event delegation for any list-shaped UI: tables, comment threads, autocomplete dropdowns, virtualised feeds. The break-even is somewhere around five repeated children, but the architectural simplicity of "one listener, many targets" is worth more than the micro-optimisation in most cases.

Capturing has its uses

The case for the capturing phase is narrow but real. Anything that needs to see an event before any descendant handler does, or that needs to run regardless of what descendants do with the event, should live in the capturing phase.

The classic example is a global click-outside handler for a popover. If the popover registers a bubbling listener on document to close itself when the user clicks elsewhere, an inner handler that calls stopPropagation will silently break the close behavior. Register the listener for the capturing phase instead and it sees the click before any descendant can stop it.

document.addEventListener('click', (e) => {
    if (popover.contains(e.target)) return;
    closePopover();
}, true);   // capturing: runs before any inner stopPropagation

The other case I reach for capturing is analytics or audit logging. If product wants to log every click on the page regardless of what application code does with it, the only safe place is the capturing phase on document or window. Anything in the bubbling phase is one rogue stopPropagation away from going dark.

Synthetic events in React

React does not attach listeners directly to DOM nodes by default; it attaches a handful of root-level listeners and dispatches synthetic events from there. The propagation model is the same three-phase walk, but the listeners are React's, not the browser's.

Two consequences trip people up. First, mixing native and React handlers in the same tree means the React root listener fires only after the native bubbling phase has already started, so a native stopPropagation can prevent React from ever seeing the event. Second, calling e.stopPropagation() on a React synthetic event stops the synthetic walk only; native listeners higher up the real DOM still run. The two systems are layered, not merged.

The defense in a mixed codebase is the same defense that works in vanilla JS: pick a phase deliberately, document which one, and use stopPropagation only when you can show a real reason. Spraying it preemptively is how you build event interactions you cannot debug six months later.

A short recognition checklist

When propagation surprises me on a real project, the diagnosis usually walks through these questions in order.

QuestionIf yes, look at
Did the handler fire when I expected nothing?Bubbling: an ancestor's listener saw the event
Did the handler not fire when I expected it to?A descendant called stopPropagation
Did two handlers on the same node fire when I only wanted one?stopImmediatePropagation instead of stopPropagation
Is the event a scroll, touchmove, or wheel?The listener may need { passive: false } to call preventDefault
Did the listener fire too early or too late relative to another?The two listeners are on different phases
Is this a React app?The synthetic-event walk is layered on top of the native walk

The checklist is not exhaustive, but it covers more than 90 percent of the propagation bugs I have actually had to debug.

Pick the phase before you write the listener

The single change that flipped event-handling from a source of bugs to a tool I trusted was the habit of asking, every time I write addEventListener, which phase this listener belongs in. The answer is almost always bubbling, but making it a deliberate answer instead of a default removes a whole class of "why did this not fire" tickets.

Capturing for global, must-run-first concerns. Bubbling for local logic and delegation. stopPropagation only when the event has been fully handled and the rest of the tree must not see it. stopImmediatePropagation only when sibling listeners on the same node also need to be silenced. preventDefault only when the browser's built-in behavior is the wrong answer for this case. Five rules, one method per concern, and the three-phase model from the top of this piece is the substrate they all rest on.

Back to Articles