Event Bubbling vs. Capturing in JavaScript

Hero image for Event Bubbling vs. Capturing in JavaScript. Image by Tianyi Ma.
Hero image for 'Event Bubbling vs. Capturing in JavaScript.' Image by Tianyi Ma.

If you have ever clicked a button and watched several event handlers fire in places you did not expect, you have already met event propagation. The only question is whether you understood what the browser was doing at the time.

Beginners often learn that clicks can "bubble up" through the DOM, but that halfexplanation usually leaves out capturing, the target phase, and the reason some handlers run before others. That is where the confusion comes from. People remember one loose rule and then hit code that does not seem to follow it.

The browser is being consistent. It is just following a fuller model than most introductions bother to explain.


There are Three Phases to a DOM Event

When an event happens on an element, the browser does not simply fire one callback in one place and call it a day. The event moves through phases.

The useful mental model is:

  1. capturing phase: the event moves from outer ancestors down towards the target
  2. target phase: the event reaches the element where it actually happened
  3. bubbling phase: the event moves back up through ancestors

That is why a click on a button can end up triggering handlers on the button itself, its parent, its grandparent, and beyond.

If you only remember "events bubble", you miss the fact that the browser can also run handlers on the way down if you register them for the capturing phase.


Bubbling is the Default Most Developers See First

By default, addEventListener listens during the bubbling phase.

const panel = document.querySelector('.panel');const button = document.querySelector('.save-button');panel?.addEventListener('click', () => {  console.log('Panel clicked');});button?.addEventListener('click', () => {  console.log('Button clicked');});

If the button is inside the panel and you click the button, the button handler runs and then the panel handler runs as the event bubbles back up.

That is the behaviour most people notice first because it is the default, and it is the basis for event delegation as well.


Capturing Runs First If You Ask for It

You can register a handler during the capturing phase by passing the appropriate option to addEventListener.

panel?.addEventListener(  'click',  () => {    console.log('Panel capture');  },  { capture: true },);

Now the panel's capturing handler can run on the way down before the event reaches the button.

That means the browser can call handlers in an order like this:

  • outer ancestor capture
  • inner ancestor capture
  • target
  • inner ancestor bubble
  • outer ancestor bubble

Once you understand that order, a lot of "why did that fire first?" moments stop being mysterious.


A Small Example Makes the Order Clearer

Imagine markup like this:

<div class="page">  <div class="panel">    <button class="save-button">Save</button>  </div></div>

and listeners like these:

const page = document.querySelector('.page');const panel = document.querySelector('.panel');const button = document.querySelector('.save-button');page?.addEventListener('click', () => {  console.log('page bubble');});page?.addEventListener(  'click',  () => {    console.log('page capture');  },  { capture: true },);panel?.addEventListener('click', () => {  console.log('panel bubble');});button?.addEventListener('click', () => {  console.log('button target');});

Clicking the button will usually log in this order:

  • page capture
  • button target
  • panel bubble
  • page bubble

That is not arbitrary. It reflects the event moving down during capture, reaching the target, then moving back up during bubbling.


event.target and event.currentTarget are not the same

This is another important point that becomes easier once you understand propagation.

event.target is the element where the event actually happened.

event.currentTarget is the element whose listener is running right now.

That means if you click a button inside a panel:

  • the target may be the button
  • the current target may be the panel if the panel's handler is running

That distinction is extremely useful in bubbling code and event delegation.

panel?.addEventListener('click', (event) => {  const target = event.target as HTMLElement;  const currentTarget = event.currentTarget as HTMLElement;  console.log(target.className);  console.log(currentTarget.className);});

If you blur those two ideas together, propagation bugs become much harder to reason about.


Why Bubbling is so Useful for Event Delegation

Event delegation works because bubbling exists.

Instead of attaching a click handler to every individual child element, you can attach one handler higher up and inspect the event target.

const list = document.querySelector('.results');list?.addEventListener('click', (event) => {  const target = event.target as HTMLElement;  const button = target.closest<HTMLButtonElement>('.remove-button');  if (!button) {    return;  }  console.log('Remove item', button.dataset.id);});

That pattern is powerful because it keeps the number of listeners down and continues to work for matching elements added later.

It also explains why understanding bubbling properly matters. Event delegation is not a trick layered on top of the DOM. It is just a practical use of the normal event flow.


When Capturing is Actually Useful

Most frontend code leans more heavily on bubbling, but capturing still has its place.

Capturing can be useful when:

  • you want an ancestor to observe an event early
  • you need a handler to run before bubbling listeners lower in the tree
  • you are building infrastructurestyle behaviour such as analytics or global interaction handling

It is not something most beginners need every day, but it is helpful to know it exists so capturephase handlers do not feel bizarre when you eventually meet them.


stopPropagation() makes more sense once you know the phases

Developers often reach for event.stopPropagation() when event flow feels messy, but it helps to understand what it is actually stopping.

It stops the event continuing through the propagation path.

That means it can prevent ancestor listeners from running later in the journey. Used carefully, that can be useful. Used casually, it can make event behaviour much harder to understand because other code higher up silently stops seeing the interaction.

Knowing the event phases makes this decision less blunt. Instead of randomly blocking propagation, you can be more deliberate about where and why you are interrupting the path.


The Browser is Not Improvising

This is the main lesson beginners need.

When several handlers run around a single interaction, the browser is not guessing. It is following a defined path:

  • down during capture
  • at the target
  • back up during bubble

If your code seems to behave strangely, the strange part is usually your mental model, not the browser.


Wrapping up

Event bubbling and capturing are simply two parts of the DOM event propagation model. Bubbling is the upward phase most developers notice first, whilst capturing is the downward phase that runs before the target is reached. Once you understand those phases, event order, delegation, and propagation bugs become much easier to reason about.

Key Takeaways

  • DOM events move through capture, target, and bubble phases.
  • Bubbling is the default phase used by most addEventListener calls.
  • Capturing runs first when you register a listener with { capture: true }.
  • event.target is where the event happened; event.currentTarget is where the current listener is attached.
  • Event delegation works because bubbling allows ancestor elements to observe child interactions.

Once you understand the full journey an event takes through the DOM, the behaviour stops feeling magical and starts feeling predictable.


Categories:

  1. Development
  2. Front‑End Development
  3. Guides
  4. JavaScript