Nerdz Logo

Bookmarks

Understanding JavaScript Event Propagation

January 7, 2026   •   8 min read

JavaScript event propagation flow illustration

When you click a button nested inside a div, which element “hears” the click first? Understanding event propagation is crucial for building interactive web applications and avoiding mysterious bugs.

Think of event propagation like dropping a stone in a pond. The ripple doesn’t just affect the exact point where the stone landed—it travels outward through the water. Similarly, when you click a nested element, the event travels through the DOM tree, giving multiple elements a chance to respond.

What is event propagation?

Event propagation is the mechanism that determines the order in which event handlers are executed when an event occurs on nested elements. It describes how events travel through the DOM tree.

When an event is triggered on an element (like a click on a button), the event doesn’t just fire on that element. It travels through the entire DOM tree in three distinct phases:

  1. Capturing phase (trickling down)
  2. Target phase (on the element itself)
  3. Bubbling phase (bubbling up)

The three phases of event propagation

1. Capturing Phase (Capture)

The event starts from the window and travels down through the DOM tree toward the target element. Along the way, it passes through all ancestor elements.

<div id="grandparent">
  <div id="parent">
    <button id="child">Click me</button>
  </div>
</div>

When you click the button, the event travels: window → document → html → body → grandparent → parent → child

2. Target Phase

The event reaches the actual element that was clicked (the target element). Event listeners registered on the target element fire here.

3. Bubbling Phase (Bubble)

After reaching the target, the event bubbles back up through the ancestors to the window. This is the default phase where most event listeners operate.

The path is reversed: child → parent → grandparent → body → html → document → window

Event bubbling explained

Event bubbling is the most commonly used phase. By default, when you attach an event listener using addEventListener, it listens during the bubbling phase.

const grandparent = document.getElementById('grandparent');
const parent = document.getElementById('parent');
const child = document.getElementById('child');

child.addEventListener('click', () => {
  console.log('Child clicked!');
});

parent.addEventListener('click', () => {
  console.log('Parent clicked!');
});

grandparent.addEventListener('click', () => {
  console.log('Grandparent clicked!');
});

// Click the button
// Output:
// Child clicked!
// Parent clicked!
// Grandparent clicked!

The event fires on the child first, then bubbles up to parent, then grandparent. Each ancestor gets a chance to respond.

Event capturing explained

Capturing is the opposite of bubbling. To listen during the capturing phase, pass true as the third argument to addEventListener (or use { capture: true }).

grandparent.addEventListener('click', () => {
  console.log('Grandparent (capturing)');
}, true); // Capture phase

parent.addEventListener('click', () => {
  console.log('Parent (capturing)');
}, true);

child.addEventListener('click', () => {
  console.log('Child clicked!');
});

// Click the button
// Output:
// Grandparent (capturing)
// Parent (capturing)
// Child clicked!

Capturing listeners fire first as the event trickles down, then the target’s listener fires.

Combining capture and bubble

You can mix both phases to control execution order precisely:

parent.addEventListener('click', () => {
  console.log('Parent (capture phase)');
}, true);

parent.addEventListener('click', () => {
  console.log('Parent (bubble phase)');
}, false); // or just omit the third parameter

child.addEventListener('click', () => {
  console.log('Child clicked!');
});

// Click the button
// Output:
// Parent (capture phase)
// Child clicked!
// Parent (bubble phase)

Stopping propagation

You can control event flow using these methods:

event.stopPropagation()

Prevents the event from traveling further in either direction. The event stops at the current element.

child.addEventListener('click', (event) => {
  console.log('Child clicked!');
  event.stopPropagation(); // Stop here!
});

parent.addEventListener('click', () => {
  console.log('Parent clicked!'); // Never runs
});

// Click the button
// Output:
// Child clicked!

event.stopImmediatePropagation()

Not only stops propagation to other elements, but also prevents other listeners on the same element from running.

child.addEventListener('click', (event) => {
  console.log('First listener');
  event.stopImmediatePropagation();
});

child.addEventListener('click', () => {
  console.log('Second listener'); // Never runs
});

// Click the button
// Output:
// First listener

event.preventDefault()

Prevents the default browser action (like following a link or submitting a form), but does NOT stop propagation.

document.querySelector('a').addEventListener('click', (event) => {
  event.preventDefault(); // Don't navigate
  console.log('Link clicked but not followed');
});

Event delegation

Event delegation is a powerful pattern that leverages event bubbling. Instead of attaching listeners to many child elements, attach one listener to a common ancestor.

Without delegation (inefficient)

const buttons = document.querySelectorAll('.item');

buttons.forEach(button => {
  button.addEventListener('click', handleClick);
});

// Problem: 100 buttons = 100 event listeners

With delegation (efficient)

const list = document.getElementById('list');

list.addEventListener('click', (event) => {
  if (event.target.matches('.item')) {
    handleClick(event.target);
  }
});

// One listener handles all current and future .item elements!

Benefits:

  • Fewer event listeners (better memory usage)
  • Works with dynamically added elements
  • Centralized event handling logic

The event object

Every event handler receives an event object with useful properties:

element.addEventListener('click', (event) => {
  console.log(event.target);       // Element that triggered the event
  console.log(event.currentTarget); // Element with the listener attached
  console.log(event.eventPhase);    // 1=capture, 2=target, 3=bubble
  console.log(event.bubbles);       // Can this event bubble?
  console.log(event.type);          // Event type (e.g., 'click')
});

event.target vs event.currentTarget

  • event.target: The actual element that was clicked (where the event originated)
  • event.currentTarget: The element that has the listener attached (the element you’re listening on)
document.getElementById('parent').addEventListener('click', (event) => {
  console.log('Target:', event.target.id);       // child
  console.log('CurrentTarget:', event.currentTarget.id); // parent
});

// Click the child button

Events that don’t bubble

Not all events bubble. Some common non-bubbling events:

  • focus / blur (use focusin / focusout instead—they bubble)
  • load / unload
  • mouseenter / mouseleave (use mouseover / mouseout instead)
  • scroll (in most cases)
// Won't work with delegation
parent.addEventListener('focus', () => {
  console.log('Child focused'); // Won't fire when child focuses
});

// Use focusin instead
parent.addEventListener('focusin', () => {
  console.log('Child focused'); // Works!
});

Practical use cases

1. Modal close on backdrop click

modal.addEventListener('click', (event) => {
  if (event.target === modal) {
    closeModal(); // Only close if backdrop clicked, not content
  }
});

modalContent.addEventListener('click', (event) => {
  event.stopPropagation(); // Prevent closing when clicking content
});

2. Dynamic list with actions

<ul id="todo-list">
  <li data-id="1">Task 1 <button class="delete">Delete</button></li>
  <li data-id="2">Task 2 <button class="delete">Delete</button></li>
</ul>
document.getElementById('todo-list').addEventListener('click', (event) => {
  if (event.target.matches('.delete')) {
    const li = event.target.closest('li');
    const id = li.dataset.id;
    deleteTask(id);
    li.remove();
  }
});

3. Keyboard navigation

document.addEventListener('keydown', (event) => {
  if (event.key === 'Escape') {
    closeAllModals();
  }
});

Common pitfalls

1. Forgetting that events bubble

// Bug: Clicking child triggers parent handler twice
parent.addEventListener('click', doSomething);
child.addEventListener('click', doSomething);

// Fix: Check event.target or stop propagation
child.addEventListener('click', (event) => {
  event.stopPropagation();
  doSomething();
});

2. Using stopPropagation() too liberally

Stopping propagation can break event delegation and third-party libraries that rely on bubbling. Use it sparingly and only when necessary.

// Problematic: Breaks analytics or other global listeners
button.addEventListener('click', (event) => {
  event.stopPropagation(); // Oops, analytics won't see this click
  handleButtonClick();
});

3. Memory leaks with delegation

// Bad: Creating closures for each item
list.addEventListener('click', (event) => {
  const item = event.target;
  // Be careful with large data structures in closures
});

4. Not checking event.target in delegation

// Bug: Clicking anywhere in parent triggers action
parent.addEventListener('click', () => {
  deleteItem(); // Runs even if you click padding!
});

// Fix: Check the target
parent.addEventListener('click', (event) => {
  if (event.target.matches('.delete-btn')) {
    deleteItem();
  }
});

Don’ts (best practices)

  • Don’t overuse stopPropagation()—it can break third-party code
  • Don’t attach listeners in loops—use delegation instead
  • Don’t forget to remove listeners when elements are removed (memory leaks)
  • Don’t assume all events bubble—check the event specification
  • Don’t use inline event handlers (onclick="...")—they’re harder to manage

Performance considerations

Event delegation is generally more performant, but consider:

// If you have very specific listeners, direct attachment may be clearer
specificButton.addEventListener('click', handleSpecificAction);

// Use delegation for:
// - Lists with many items
// - Dynamically added content
// - Similar actions across multiple elements

When to use what

  • Use bubbling (default): For most event handling needs
  • Use capturing: When you need to intercept events before they reach targets (rare)
  • Use delegation: For lists, grids, or dynamic content
  • Use stopPropagation(): Only when you truly need to prevent parent handlers
  • Use preventDefault(): To prevent default browser behavior (links, forms)

Quick demo

Try to predict the output:

const outer = document.getElementById('outer');
const inner = document.getElementById('inner');

outer.addEventListener('click', () => console.log('Outer (bubble)'), false);
outer.addEventListener('click', () => console.log('Outer (capture)'), true);
inner.addEventListener('click', () => console.log('Inner'));

// Click inner element
// Output:
// Outer (capture)
// Inner
// Outer (bubble)

Why? Capture phase runs first (outer capture), then target phase (inner), then bubble phase (outer bubble).

Takeaways

  • Events travel through three phases: capture → target → bubble
  • Most event handling happens in the bubble phase (default)
  • Use event delegation for better performance and dynamic content
  • event.target tells you what was clicked; event.currentTarget tells you where the listener is
  • Not all events bubble—check the documentation
  • Use stopPropagation() sparingly to avoid breaking other code
  • preventDefault() stops default behavior but NOT propagation

Debugging event propagation

element.addEventListener('click', (event) => {
  console.log('Phase:', event.eventPhase); // 1=capture, 2=target, 3=bubble
  console.log('Target:', event.target);
  console.log('CurrentTarget:', event.currentTarget);
  console.log('Bubbles:', event.bubbles);
});

Use browser DevTools to see all listeners on an element: Right-click → Inspect → Event Listeners tab.

Frequently Asked Questions

Should I use capturing or bubbling?

Use bubbling (the default) for 99% of cases. Capturing is rarely needed and is typically used for intercepting events before they reach their targets.

Why use event delegation instead of attaching listeners directly?

Delegation uses less memory, works with dynamically added elements, and centralizes your event handling logic. It's especially powerful for lists or grids with many items.

What's the difference between stopPropagation() and preventDefault()?

stopPropagation() stops the event from traveling to other elements. preventDefault() prevents the browser's default action (like following a link) but the event still propagates normally.

Can I stop an event during the capturing phase?

Yes, calling stopPropagation() during capture prevents the event from reaching the target and bubbling back up.

What happens if I call both stopPropagation() and preventDefault()?

Both take effect independently - the event stops propagating AND the default action is prevented. There's also a shorthand "return false" (but only in some contexts - not recommended).

How do I remove an event listener?

Use removeEventListener() with the exact same function reference and options. Anonymous functions can't be removed, so use named functions or store references.

Newsletter

Stay in the loop

Get the latest updates on my blog, projects, and tech insights delivered straight to your inbox. No spam, unsubscribe anytime.

By subscribing, you agree to receive occasional updates. Unsubscribe anytime.