JavaScript Event Bubbling

When you click a button that sits nested inside several layers of HTML elements, more than just that button finds out about the click. The browser fires the click event on the exact element you touched, then walks back up through every ancestor element, firing that same event on each one in turn. This upward journey is called event bubbling, and it is one of the most important mechanics to understand if you want to write efficient, predictable event-handling code in JavaScript.

Overview: How Event Bubbling Works

Every element in an HTML document is a node in a tree, the DOM (Document Object Model). When a user interacts with a page — clicking, typing, scrolling — the browser creates an Event object and dispatches it through that tree in three distinct phases:

1. Capturing phase

The event starts at the window, travels down through document, html, body, and every ancestor element, until it reaches the element that was actually interacted with. Handlers registered for the capturing phase run during this downward trip.

2. Target phase

The event arrives at the exact element the user interacted with, called the target. Handlers attached directly to this element fire here.

3. Bubbling phase

The event then travels back up the tree, from the target through each ancestor, all the way to window. Any handler attached to an ancestor that listens for this event type will fire, in order, as the event passes through it. This is the phase most developers mean when they say "event bubbling", and it is the default phase that addEventListener listens on.

Inside every handler, the event object exposes two properties that are easy to confuse: event.target is the element that originally triggered the event (where the user actually clicked), while event.currentTarget is the element the currently-running handler is attached to. During bubbling, target stays fixed but currentTarget changes as the event climbs the tree.

Not every event bubbles. Most do — click, keydown, input, submit — but a few notable ones do not, including focus, blur, mouseenter, and mouseleave. Those were designed to fire only on the exact element involved, precisely so they would not bubble and trigger unwanted handlers on parent elements. (Their bubbling cousins, focusin/focusout and mouseover/mouseout, do bubble.)

Syntax

Bubbling itself requires no special syntax — it happens automatically. What you control is which phase a listener runs in, via the third argument of addEventListener:

target.addEventListener(type, listener, useCapture);
// or, with an options object:
target.addEventListener(type, listener, { capture: true, once: false, passive: false });
  • type — a string naming the event, such as 'click' or 'keydown'.
  • listener — the function to run; receives the Event object as its argument.
  • useCapture (or options.capture) — a boolean. false or omitted (the default) means the listener fires during the bubbling phase. true means it fires during the capturing phase.
  • event.stopPropagation() — called inside a listener, stops the event from continuing to travel to further ancestors (or descendants, during capturing).
  • event.stopImmediatePropagation() — like stopPropagation, but also prevents any other listeners registered on the same element from running.

Examples

Example 1: Watching an event bubble upward

Imagine three nested elements, outer > middle > inner, each with its own click listener. Clicking the innermost element fires all three handlers, starting with the one on inner itself.

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

outer.addEventListener('click', () => console.log('Outer clicked'));
middle.addEventListener('click', () => console.log('Middle clicked'));
inner.addEventListener('click', () => console.log('Inner clicked'));

inner.click();
Inner clicked
Middle clicked
Outer clicked

The click originates on inner, so that handler runs first. The event then bubbles up to middle, and finally to outer, running each ancestor’s handler along the way — even though the user never touched middle or outer directly.

Example 2: Stopping the bubble with stopPropagation

Sometimes you want an event to be handled by one element and go no further. Calling event.stopPropagation() inside a listener prevents the event from reaching any ancestor.

const outer2 = document.getElementById('outer2');
const inner2 = document.getElementById('inner2');

outer2.addEventListener('click', () => console.log('Outer2 clicked'));
inner2.addEventListener('click', (event) => {
  console.log('Inner2 clicked');
  event.stopPropagation();
});

inner2.click();
Inner2 clicked

Because inner2‘s handler calls stopPropagation(), the event never reaches outer2, so 'Outer2 clicked' is never logged, even though outer2 has a listener registered.

Example 3: Event delegation using bubbling

Bubbling makes it possible to attach a single listener to a parent element and have it handle events for children — including children added later. This pattern is called event delegation and is one of the most useful applications of bubbling.

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

list.addEventListener('click', (event) => {
  if (event.target.matches('li')) {
    console.log(`You clicked: ${event.target.textContent}`);
  }
});

const newItem = document.createElement('li');
newItem.textContent = 'Buy milk';
list.appendChild(newItem);
newItem.click();
You clicked: Buy milk

Only one listener exists, on list, yet it correctly handles a click on newItem — an element that did not even exist when the listener was attached. That works because the click on newItem bubbles up to list, where event.target tells the handler exactly which child was actually clicked.

Under the Hood: Propagation Step by Step

Registering listeners for both phases on the same elements makes the full journey visible:

const outer3 = document.getElementById('outer3');
const inner3 = document.getElementById('inner3');

outer3.addEventListener('click', () => console.log('Outer3 - capturing phase'), true);
outer3.addEventListener('click', () => console.log('Outer3 - bubbling phase'), false);
inner3.addEventListener('click', () => console.log('Inner3 - target phase'));

inner3.click();
Outer3 - capturing phase
Inner3 - target phase
Outer3 - bubbling phase

Internally, the browser builds a propagation path from window down to the target the moment the event is created. It then walks that path top-to-bottom invoking capturing listeners, invokes any listeners on the target itself, and finally walks the same path bottom-to-top invoking bubbling listeners. This is why outer3‘s capturing listener fires before inner3‘s handler, and its bubbling listener fires after. If stopPropagation() is called at any point, the browser simply stops walking the remaining path — already-invoked listeners are unaffected.

Common Mistakes

Mistake 1: Assuming every event bubbles, and using a non-bubbling event for delegation. mouseenter and mouseleave never bubble, so trying to delegate them from a parent silently does nothing useful for descendants added later.

const container = document.getElementById('menu');

container.addEventListener('mouseenter', (event) => {
  if (event.target.matches('.menu-item')) {
    event.target.classList.add('highlight');
  }
});

This never fires for a .menu-item child, because mouseenter only triggers on the element the mouse directly entered — here, that is always container itself, never a nested item, since the event doesn’t propagate. The fix is to use the bubbling equivalent, mouseover, combined with closest() to find the relevant ancestor:

const container = document.getElementById('menu');

container.addEventListener('mouseover', (event) => {
  const item = event.target.closest('.menu-item');
  if (item && container.contains(item)) {
    item.classList.add('highlight');
  }
});

Mistake 2: Confusing event.target with event.currentTarget. In a delegated handler, event.target can be a deeply nested child (like an <svg> icon inside a button), not the element the listener was attached to.

document.getElementById('card').addEventListener('click', (event) => {
  console.log(event.target.tagName);
  event.target.classList.add('active');
});

If the card contains child elements, clicking one of them makes event.target point to that child, so 'active' gets added to the wrong element. When you specifically want the element the listener is attached to, use currentTarget instead:

document.getElementById('card').addEventListener('click', (event) => {
  console.log(event.currentTarget.tagName);
  event.currentTarget.classList.add('active');
});

Other frequent pitfalls: calling stopPropagation() too liberally, which can silently break analytics trackers, modal-close-on-outside-click logic, or other unrelated code listening higher up the tree; and forgetting that event.stopPropagation() does not prevent the browser’s default action (use event.preventDefault() for that — the two are independent and often needed together).

Best Practices

  • Prefer event delegation for lists, tables, or any collection of similar elements, especially when items are added or removed dynamically — it avoids attaching (and forgetting to remove) a listener per item.
  • Inside a delegated handler, use event.target.closest(selector) rather than event.target.matches(selector) alone, so clicks on nested children (like an icon inside a link) are still matched correctly.
  • Reach for event.currentTarget when you need the element the listener is bound to, and event.target when you need to know exactly what the user interacted with.
  • Use stopPropagation() sparingly and deliberately — document why, since it can silently break other code relying on the event reaching higher elements.
  • Remember which events don’t bubble (focus, blur, mouseenter, mouseleave) and reach for their bubbling counterparts (focusin, focusout, mouseover, mouseout) when delegation is required.
  • Only set the capturing phase (true or { capture: true }) when you specifically need to intercept an event before it reaches its target, such as implementing global keyboard shortcuts that must run before a component’s own handlers.

Practice Exercises

  • Exercise 1: Build a page with a <ul> containing five <li> items. Attach a single click listener to the <ul> that logs the text of whichever <li> was clicked, using event delegation.
  • Exercise 2: Create three nested <div> elements. Attach click listeners to all three that log their own name. Add a button inside the innermost <div> that, when clicked, calls stopPropagation() so only the innermost handler runs. Verify the outer handlers do not fire.
  • Exercise 3: Add both a capturing and a bubbling listener to the same outer element, plus a target listener on an inner element. Predict the console output order on paper, then compare it against what actually logs.

Summary

  • Events travel through the DOM in three phases: capturing (top-down), target, and bubbling (bottom-up).
  • addEventListener‘s third argument controls whether a listener runs during capturing (true) or bubbling (false, the default).
  • event.target is the element that triggered the event; event.currentTarget is the element the running handler is attached to.
  • event.stopPropagation() halts further travel along the propagation path but does not cancel the browser’s default action.
  • Not all events bubble — focus, blur, mouseenter, and mouseleave notably do not.
  • Event delegation exploits bubbling to handle events for many (including future) child elements with a single listener, improving performance and simplifying dynamic UIs.