Skip to content
JavaScript js events 5 min read

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:

  1. Capture phase — the event descends from the window down through each ancestor toward the target element.
  2. Target phase — the event reaches the element the user actually interacted with.
  3. 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 capture and bubble handlers 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");
});
MethodStops travel to other elementsStops other handlers on same element
stopPropagation()YesNo
stopImmediatePropagation()YesYes

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.target to 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.target over stopPropagation() so you do not silently break delegation or document-level listeners elsewhere on the page.
  • Use event.currentTarget (not this in arrow callbacks) to reference the element you attached the listener to.
  • Remember focus/blur do not bubble — use focusin/focusout when 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 (including capture) that you used to add the listener, or it will not be removed.
Last updated June 1, 2026
Was this helpful?