Skip to main content

Event Bubbling and Event Capturing in JavaScript

When you click a button on a web page, you are not just clicking that button. You are also clicking the <div> that contains it, the <section> that contains that <div>, the <body>, the <html> element, and the document itself. Every element in the ancestry chain from the document root down to the button is involved in the click. JavaScript's event system reflects this reality through a mechanism called event propagation.

Event propagation has three phases. First, the event travels down from the document to the target element (capturing phase). Then it triggers on the target itself (target phase). Finally, it travels back up from the target to the document (bubbling phase). Understanding these phases gives you precise control over when and where your event handlers fire, enables powerful patterns like event delegation, and helps you debug confusing situations where events seem to trigger on elements you did not expect.

This guide walks through each phase with clear visual examples, explains the critical difference between event.target and event.currentTarget, and shows you when and how to stop propagation.

Event Bubbling: From Target Up to Document

Bubbling is the default and most commonly used phase. When an event fires on an element, it first triggers handlers on that element, then on its parent, then on the parent's parent, and so on, all the way up to the document and window.

The event "bubbles up" through the DOM tree, like a bubble rising through water.

Basic Bubbling Example

<div id="grandparent" style="padding: 30px; background: #f0f0f0;">
Grandparent
<div id="parent" style="padding: 30px; background: #d0d0d0;">
Parent
<button id="child">Click Me</button>
</div>
</div>
document.getElementById("grandparent").addEventListener("click", () => {
console.log("Grandparent clicked");
});

document.getElementById("parent").addEventListener("click", () => {
console.log("Parent clicked");
});

document.getElementById("child").addEventListener("click", () => {
console.log("Child (button) clicked");
});

When you click the button, the output is:

Child (button) clicked
Parent clicked
Grandparent clicked

The event starts at the button (the element you actually clicked), then bubbles up to the parent <div>, then to the grandparent <div>. Each handler fires in order from the target upward.

Bubbling Path Visualization

Click on button:

document ← 5. Bubbles here last
└── html ← 4.
└── body ← 3.
└── #grandparent ← 2. "Grandparent clicked"
└── #parent ← 1. Fires SECOND: "Parent clicked"
└── button ← 0. Fires FIRST: "Child clicked"

Click happens here

Bubbling Goes All the Way Up

The event does not stop at the nearest ancestor with a handler. It visits every ancestor, whether or not they have handlers attached:

document.addEventListener("click", () => {
console.log("Document clicked");
});

window.addEventListener("click", () => {
console.log("Window clicked");
});

Clicking the button now produces:

Child (button) clicked
Parent clicked
Grandparent clicked
Document clicked
Window clicked

Not All Events Bubble

Most events bubble, but a few do not. Events that are specific to a particular element and do not make sense in the context of ancestors do not bubble:

Events That BubbleEvents That Do NOT Bubble
click, dblclickfocus, blur
mousedown, mouseupmouseenter, mouseleave
mouseover, mouseoutload, unload, error
keydown, keyupresize, scroll (on elements)
input, change
submit

For focus and blur, use focusin and focusout instead if you need bubbling:

// focus does NOT bubble: handler on parent won't fire
parent.addEventListener("focus", () => {
console.log("focus on parent"); // Never fires for child focus
});

// focusin DOES bubble: handler on parent fires
parent.addEventListener("focusin", () => {
console.log("focusin on parent"); // Fires when any child gains focus
});

You can check if an event bubbles by reading the event.bubbles property:

document.addEventListener("click", event => {
console.log("Does click bubble?", event.bubbles); // true
});

document.getElementById("input").addEventListener("focus", event => {
console.log("Does focus bubble?", event.bubbles); // false
});

event.target vs. event.currentTarget

This is one of the most important distinctions in event handling. When an event bubbles, the same event object passes through every handler on every ancestor. Two properties on the event object tell you different things:

  • event.target: the element where the event originated (the deepest element that was actually clicked, typed into, etc.). It stays the same throughout the entire propagation.
  • event.currentTarget: the element whose handler is currently running. It changes as the event bubbles through different elements.

Demonstrating the Difference

<div id="outer" style="padding: 40px; background: coral;">
<div id="inner" style="padding: 40px; background: gold;">
<button id="btn">Click Me</button>
</div>
</div>
function handleClick(event) {
console.log(`target: ${event.target.id}, currentTarget: ${event.currentTarget.id}`);
}

document.getElementById("outer").addEventListener("click", handleClick);
document.getElementById("inner").addEventListener("click", handleClick);
document.getElementById("btn").addEventListener("click", handleClick);

Clicking the button:

target: btn, currentTarget: btn
target: btn, currentTarget: inner
target: btn, currentTarget: outer

event.target is always btn because that is where the click originated. event.currentTarget changes with each handler: first btn, then inner, then outer.

Clicking the inner div (not the button):

target: inner, currentTarget: inner
target: inner, currentTarget: outer

Now event.target is inner because the click originated there. The button's handler does not fire because the event does not bubble downward.

this vs. event.currentTarget

Inside an event handler added with addEventListener, this is the same as event.currentTarget (the element the handler is attached to):

document.getElementById("outer").addEventListener("click", function(event) {
console.log(this === event.currentTarget); // true
console.log(this.id); // "outer"
});
warning

Arrow functions do not have their own this. If you use an arrow function as an event handler, this will not refer to the element. Use event.currentTarget instead:

// Regular function: this === event.currentTarget
element.addEventListener("click", function(event) {
console.log(this.id); // Works ("element's id")
});

// Arrow function: this is inherited from outer scope (probably window)
element.addEventListener("click", (event) => {
console.log(this.id); // Does NOT work as expected
console.log(event.currentTarget.id); // Works (always correct)
});

Practical Use: Event Delegation

The difference between target and currentTarget is the foundation of event delegation, where you attach a single handler on a parent to handle events from all children:

document.getElementById("menu").addEventListener("click", function(event) {
// currentTarget is always #menu (where the handler lives)
// target is the actual element clicked (could be any descendant)

const clickedItem = event.target.closest("li");

if (clickedItem && this.contains(clickedItem)) {
console.log("Menu item clicked:", clickedItem.textContent);
}
});

Stopping Bubbling: stopPropagation() and stopImmediatePropagation()

Sometimes you need to prevent an event from continuing up the DOM tree. JavaScript provides two methods for this.

event.stopPropagation()

Stops the event from bubbling to parent elements. Handlers on the current element still run, but handlers on ancestors do not:

document.getElementById("grandparent").addEventListener("click", () => {
console.log("Grandparent"); // Will NOT run if stopped at parent or child
});

document.getElementById("parent").addEventListener("click", () => {
console.log("Parent"); // Will NOT run if stopped at child
});

document.getElementById("child").addEventListener("click", (event) => {
console.log("Child");
event.stopPropagation(); // Stop bubbling here
});

Clicking the button:

Child

Only the child's handler runs. The event stops bubbling and never reaches parent or grandparent.

event.stopImmediatePropagation()

Goes further: it stops bubbling and prevents any remaining handlers on the same element from running:

const button = document.getElementById("btn");

button.addEventListener("click", (event) => {
console.log("Handler 1");
event.stopImmediatePropagation();
});

button.addEventListener("click", () => {
console.log("Handler 2"); // Will NOT run
});

document.getElementById("parent").addEventListener("click", () => {
console.log("Parent"); // Will NOT run either
});

Clicking the button:

Handler 1

Handler 2 on the same button is skipped, and bubbling to parent is also prevented.

Comparison

const btn = document.getElementById("btn");

// Scenario 1: stopPropagation
btn.addEventListener("click", (e) => { console.log("A"); e.stopPropagation(); });
btn.addEventListener("click", () => { console.log("B"); }); // Runs
parent.addEventListener("click", () => { console.log("C"); }); // Blocked
// Output: A, B

// Scenario 2: stopImmediatePropagation
btn.addEventListener("click", (e) => { console.log("A"); e.stopImmediatePropagation(); });
btn.addEventListener("click", () => { console.log("B"); }); // Blocked
parent.addEventListener("click", () => { console.log("C"); }); // Blocked
// Output: A

When to Stop Bubbling

Stopping propagation should be done carefully and sparingly. Overusing it can create hard-to-debug problems where event handlers you expect to fire are silently prevented.

Legitimate reasons to stop propagation:

// 1. A modal or dropdown that should capture all clicks inside it
// and prevent the background click handler from closing it
modal.addEventListener("click", (event) => {
event.stopPropagation();
// Clicks inside the modal don't reach the backdrop
});

backdrop.addEventListener("click", () => {
closeModal(); // Only fires for clicks OUTSIDE the modal
});
// 2. Nested interactive elements where you need to prevent parent action
document.getElementById("card").addEventListener("click", () => {
navigateToDetails(); // Clicking anywhere on the card navigates
});

document.getElementById("delete-btn").addEventListener("click", (event) => {
event.stopPropagation(); // Don't navigate (just delete)
deleteItem();
});

The problem with excessive stopPropagation():

// Some component stops propagation
button.addEventListener("click", (event) => {
event.stopPropagation();
doSomething();
});

// Later, an analytics handler on document never fires for this button
document.addEventListener("click", (event) => {
trackClick(event.target); // Never receives clicks from the button!
});
caution

stopPropagation() blocks all handlers further up the chain, including ones you may not be aware of (analytics trackers, global keyboard shortcuts, accessibility tools). Prefer checking event.target in parent handlers over stopping propagation:

// Instead of stopping propagation:
inner.addEventListener("click", (e) => {
e.stopPropagation(); // Breaks parent handlers you don't know about
handleInner();
});

// Prefer checking target in the parent:
outer.addEventListener("click", (e) => {
if (e.target.closest("#inner")) return; // Skip if from inner
handleOuter();
});

Event Capturing: From Document Down to Target

Capturing is the mirror image of bubbling. Instead of going from target up to document, the event travels from document down to target. This phase happens before the target phase and bubbling phase.

By default, addEventListener registers handlers for the bubbling phase. To listen during the capturing phase, pass true or { capture: true } as the third argument:

Enabling Capture Handlers

// Bubbling (default)
element.addEventListener("click", handler);
element.addEventListener("click", handler, false);
element.addEventListener("click", handler, { capture: false });

// Capturing
element.addEventListener("click", handler, true);
element.addEventListener("click", handler, { capture: true });

Capturing in Action

<div id="outer">
<div id="inner">
<button id="btn">Click Me</button>
</div>
</div>
function captureHandler(event) {
console.log(`CAPTURE: ${event.currentTarget.id}`);
}

function bubbleHandler(event) {
console.log(`BUBBLE: ${event.currentTarget.id}`);
}

// Register capture handlers (third argument = true)
document.getElementById("outer").addEventListener("click", captureHandler, true);
document.getElementById("inner").addEventListener("click", captureHandler, true);
document.getElementById("btn").addEventListener("click", captureHandler, true);

// Register bubble handlers (default)
document.getElementById("outer").addEventListener("click", bubbleHandler);
document.getElementById("inner").addEventListener("click", bubbleHandler);
document.getElementById("btn").addEventListener("click", bubbleHandler);

Clicking the button:

CAPTURE: outer
CAPTURE: inner
CAPTURE: btn
BUBBLE: btn
BUBBLE: inner
BUBBLE: outer

The event travels down during capture (outer → inner → btn) and then back up during bubbling (btn → inner → outer).

Capture vs. Bubble Phase Visualization

         CAPTURE PHASE               BUBBLE PHASE
(going down) (going up)

1. document ──────────► 6. document
2. html ──────────► 5. html
3. body ──────────► 4. body
4. #outer ──────────► 3. #outer
5. #inner ──────────► 2. #inner
6. button ═══════════ 1. button ← TARGET PHASE
(handlers fire here
in registration order)

event.eventPhase

You can check which phase the event is currently in using event.eventPhase:

element.addEventListener("click", (event) => {
switch (event.eventPhase) {
case Event.CAPTURING_PHASE: // 1
console.log("Capturing phase");
break;
case Event.AT_TARGET: // 2
console.log("At target");
break;
case Event.BUBBLING_PHASE: // 3
console.log("Bubbling phase");
break;
}
}, true); // Using capture

Removing Capture Handlers

When removing event listeners, you must match the capture option. A handler added with capture: true can only be removed with capture: true:

function handler(event) {
console.log("Clicked");
}

// Add capture handler
element.addEventListener("click", handler, true);

// WRONG: does not remove the capture handler (removes a non-existent bubble handler)
element.removeEventListener("click", handler);
element.removeEventListener("click", handler, false);

// CORRECT: matches the capture flag
element.removeEventListener("click", handler, true);

The Three Phases: Capture, Target, Bubble

Every DOM event goes through exactly three phases in this order:

Phase 1: Capturing (Going Down)

The event starts at window and travels down through every ancestor of the target element. At each ancestor, any capture-phase handlers fire.

Phase 2: Target

The event reaches the target element (the element where the event originated). Handlers on the target fire, regardless of whether they were registered for capture or bubble.

Phase 3: Bubbling (Going Up)

The event travels back up from the target through every ancestor to window. At each ancestor, any bubble-phase handlers fire.

Complete Three-Phase Example

const elements = ["outer", "inner", "btn"];

elements.forEach(id => {
const el = document.getElementById(id);

// Capture handler
el.addEventListener("click", (e) => {
console.log(`▼ CAPTURE on ${id} (phase: ${e.eventPhase})`);
}, true);

// Bubble handler
el.addEventListener("click", (e) => {
console.log(`▲ BUBBLE on ${id} (phase: ${e.eventPhase})`);
}, false);
});

Clicking the button:

▼ CAPTURE on outer (phase: 1)
▼ CAPTURE on inner (phase: 1)
▼ CAPTURE on btn (phase: 2)
▲ BUBBLE on btn (phase: 2)
▲ BUBBLE on inner (phase: 3)
▲ BUBBLE on outer (phase: 3)

Notice that on the target element (btn), both capture and bubble handlers show phase: 2 (AT_TARGET). At the target, the distinction between capture and bubble disappears. Handlers fire in the order they were registered:

const btn = document.getElementById("btn");

// Register bubble first, then capture
btn.addEventListener("click", () => console.log("Bubble handler"), false);
btn.addEventListener("click", () => console.log("Capture handler"), true);

Clicking the button:

Bubble handler
Capture handler

On the target element itself, the handlers run in registration order, not capture-then-bubble order. This is a subtle but important detail.

Stopping Propagation Affects Both Phases

stopPropagation() stops the event from continuing in whichever phase it is currently in:

// Stop during capture: event never reaches the target or bubbles
document.getElementById("inner").addEventListener("click", (e) => {
console.log("Captured at inner - stopping");
e.stopPropagation();
}, true);

document.getElementById("btn").addEventListener("click", () => {
console.log("Button handler"); // Never runs!
});

document.getElementById("outer").addEventListener("click", () => {
console.log("Outer bubble handler"); // Never runs!
});

Clicking the button:

Captured at inner - stopping

The event is stopped during the capture phase at inner. It never reaches the target (btn) and never bubbles.

Why Bubbling Is Usually Enough

Despite the three-phase model, the vast majority of event handling uses only the bubbling phase. Capturing exists primarily for historical reasons and a few specific use cases.

Why Bubble-Phase Handlers Are the Default

When you write element.addEventListener("click", handler), you get a bubble-phase handler. This is by design because:

  1. Event delegation works with bubbling. You put one handler on a parent, and it catches events from all descendants as they bubble up. This is the most important event pattern in modern JavaScript.

  2. The target fires first. With bubbling, the element the user actually interacted with handles the event first, then its parent, then the grandparent. This matches the intuitive expectation that specific elements should respond before general ones.

  3. Most use cases are about reacting to events. You want to know when something was clicked, typed, or submitted. Bubbling delivers the event to the target first and then to any parent that also cares.

When Capturing Is Useful

There are a few legitimate use cases for capture-phase handlers:

1. Intercepting events before they reach the target:

// A form validation system that can prevent interaction with form fields
form.addEventListener("click", (event) => {
if (formIsDisabled) {
event.stopPropagation();
event.preventDefault();
showMessage("Form is currently disabled");
}
}, true); // Capture phase: fires before any field handler

Because the capture handler fires before the target and bubbling handlers, it can intercept and stop the event before any child element sees it.

2. Focus management:

// Track focus changes across a complex UI component
container.addEventListener("focus", (event) => {
console.log("Focus entering:", event.target);
highlightActiveSection(event.target);
}, true); // focus doesn't bubble, but it IS captured

container.addEventListener("blur", (event) => {
console.log("Focus leaving:", event.target);
removeHighlight(event.target);
}, true); // blur doesn't bubble, but it IS captured

Since focus and blur do not bubble, capturing is the only way for a parent to detect them.

3. Logging and analytics that must fire regardless of stopPropagation in handlers below:

// Analytics capture handler fires before any component can stopPropagation
document.addEventListener("click", (event) => {
analytics.track("click", {
target: event.target.tagName,
id: event.target.id
});
}, true); // Capture phase: fires first, before any stopPropagation call

If a component's bubble-phase handler calls stopPropagation(), a capture-phase handler on document still fires because capture happens before bubbling.

The Practical Recommendation

// For 99% of cases: use bubble phase (default)
element.addEventListener("click", handler);

// For the rare cases listed above: use capture phase
element.addEventListener("click", handler, true);
tip

If you are building a component library or handling complex nested interactions, understanding capturing gives you more options. But for day-to-day development, bubble-phase handlers with event delegation cover almost everything. When you encounter a problem that seems to require capturing, first consider whether event delegation or checking event.target can solve it with regular bubbling.

Quick Reference: Propagation Behavior

// Bubbling: target → parent → grandparent → ... → document → window
// Default behavior. Most handlers use this.

// Capturing: window → document → ... → grandparent → parent → target
// Activated with { capture: true }. Rarely needed.

// stopPropagation: stops the event from continuing to the next element
// (in whichever phase it's currently in)

// stopImmediatePropagation: stops propagation AND prevents remaining
// handlers on the SAME element from running

// event.target: the element where the event ORIGINATED (doesn't change)
// event.currentTarget: the element whose handler is CURRENTLY RUNNING (changes)
// this (in regular functions): same as event.currentTarget
// event.eventPhase: 1 = capturing, 2 = at target, 3 = bubbling

Summary

Event propagation is the mechanism by which a single event triggers handlers on multiple elements in the DOM tree. Understanding the three phases and the difference between target and currentTarget is essential for effective event handling.

ConceptKey Point
BubblingEvents travel from target element up to document and window. Default phase for handlers.
CapturingEvents travel from window down to the target element. Activated with { capture: true }.
Three phasesCapture (down) → Target → Bubble (up). Every event follows this order.
event.targetThe element where the event originated. Never changes during propagation.
event.currentTargetThe element whose handler is currently executing. Changes as the event propagates.
this in handlersSame as event.currentTarget in regular functions. Does not work with arrow functions.
stopPropagation()Prevents the event from reaching further elements, but other handlers on the same element still fire.
stopImmediatePropagation()Prevents propagation and remaining handlers on the same element.
Non-bubbling eventsfocus, blur, mouseenter, mouseleave, load, error do not bubble. Use focusin/focusout for bubbling alternatives.
Event delegationUses bubbling: one handler on a parent element handles events from all descendants.
At-target phaseOn the target itself, handlers fire in registration order regardless of capture/bubble flag.

Key rules to remember:

  • Use bubble-phase handlers (the default) for nearly everything
  • Use event.target to identify what was actually clicked, and event.currentTarget (or this) to identify where the handler is attached
  • Avoid stopPropagation() unless you have a specific reason, because it silently breaks handlers further up the tree
  • Capture-phase handlers fire before bubble-phase handlers, which is useful for intercepting events before targets see them
  • Non-bubbling events like focus can still be captured. Use { capture: true } on a parent to detect them.
  • When removing listeners, the capture flag must match the one used during addEventListener