Articles

Event Bubbling vs Capturing in JavaScript, Explained

Event bubbling and capturing are the two directions a DOM event travels. How propagation phases work, when to use each, and how to stop them.

Takina Takina · · 5 min read
Dark-themed code editor showing JavaScript source

Event bubbling and event capturing are the two directions a DOM event travels through the element tree. When you click a button nested inside a form inside a <main>, the browser doesn’t just notify the button. It walks the event down from window to the button (the capture phase), fires it on the button itself (the target phase), and then walks it back up to window (the bubble phase). Listeners run in bubbling order by default; you opt into capturing with a flag. Knowing which phase your listener lives in explains most “why did this handler fire twice” and “why didn’t my handler fire at all” bugs.

The three phases of event propagation

Every dispatched event that participates in propagation goes through the same sequence:

  1. Capture phase. The event starts at window, then visits document, <html>, <body>, and every ancestor of the target in order, moving downward.
  2. Target phase. The event reaches the element that triggered it — event.target. Listeners registered on the target itself run here.
  3. Bubble phase. The event retraces its path upward, from the target’s parent back to window.

You can check where you are with event.eventPhase, which returns 1 for capturing, 2 for at-target, and 3 for bubbling. Inside any listener, event.target is the element where the event originated, while event.currentTarget is the element whose listener is running right now. Mixing those two up is one of the most common propagation mistakes.

How to listen in each phase

addEventListener takes an optional third argument. Pass nothing (or false) and the listener runs during the bubble phase. Pass true, or an options object with capture: true, and it runs during the capture phase.

const outer = document.querySelector("#outer");
const inner = document.querySelector("#inner");

outer.addEventListener("click", () => console.log("outer capture"), { capture: true });
outer.addEventListener("click", () => console.log("outer bubble"));
inner.addEventListener("click", () => console.log("inner"));

// Clicking #inner logs:
// outer capture
// inner
// outer bubble

One detail trips people up: when removing a listener, the capture flag must match. removeEventListener("click", fn) will not remove a listener added with { capture: true }. If you use an AbortController and pass its signal in the options object instead, you sidestep that bookkeeping entirely — aborting the signal removes the listener regardless of phase.

Bubbling vs capturing at a glance

CapturingBubbling
Directionwindow down to targetTarget up to window
OrderRuns firstRuns last
How to opt in{ capture: true } or trueDefault
Typical useIntercepting before children reactDelegation, general handling
Applies to non-bubbling eventsYesNo

Why bubbling is the default

Bubbling is what makes event delegation possible. Instead of attaching a click handler to every row in a table with a thousand rows, you attach one handler to the table and inspect event.target to figure out which row was clicked. New rows added later work automatically because the handler lives on a stable ancestor.

table.addEventListener("click", (event) => {
  const row = event.target.closest("tr[data-id]");
  if (!row || !table.contains(row)) return;
  openRecord(row.dataset.id);
});

The closest() call matters. The click target may be a <span> or icon inside the cell, not the row itself, so you walk upward from the target to find the element you actually care about.

When capturing is the right tool

Capturing is less common but genuinely useful in a few situations:

  • Intercepting before children see the event. An analytics layer or a “click outside to close” handler can listen in the capture phase on document and observe every click before component-level handlers have a chance to stop propagation.
  • Handling events that don’t bubble. focus, blur, mouseenter, mouseleave, load, and scroll on elements do not bubble. A capture-phase listener on an ancestor still sees them on the way down. That’s how you can track focus changes inside a container with a single listener — though focusin and focusout, which do bubble, are usually the cleaner choice for that specific case.
  • Guarding a region. Modal implementations sometimes capture keyboard events at the container level to manage focus trapping. The native dialog element handles much of this for you now, which is a good reason to prefer it.

Stopping propagation, and why to be careful

Three methods change how an event moves:

  • event.stopPropagation() halts the event at the current element. Listeners further along the path — deeper in the capture phase or higher in the bubble phase — won’t run. Other listeners on the same element still run.
  • event.stopImmediatePropagation() does the same, and also prevents any remaining listeners on the current element from running.
  • event.preventDefault() is unrelated to propagation. It cancels the browser’s default action (following a link, submitting a form, checking a checkbox) but lets the event keep traveling.

Use stopPropagation() sparingly. It’s tempting as a quick fix for a parent handler that fires when it shouldn’t, but it silently breaks everything above that point: delegated handlers, analytics, “click outside” detection, and third-party widgets that rely on document-level listeners. A better pattern is usually to have the parent handler check event.target and bail out when the click came from somewhere it doesn’t care about.

Propagation and performance

Propagation itself is cheap; the browser walks a path it has already computed. What costs time is the work your listeners do. Delegation reduces memory and setup cost by replacing many listeners with one, which helps on large lists. For scroll and touch events, mark listeners as passive when they never call preventDefault(), so the browser can scroll without waiting for your JavaScript to finish.

It also helps to remember that event handlers are just tasks on the main thread. A slow handler on a bubbling ancestor runs for every matching event inside it, and it competes with rendering the same way any other task does — the event loop doesn’t care which phase scheduled the work.

Custom events and shadow DOM

Events you create with new CustomEvent("name") do not bubble unless you pass { bubbles: true }. If you dispatch a custom event from a component and the parent never hears about it, a missing bubbles flag is the first thing to check.

Shadow DOM adds one more wrinkle. By default, events that originate inside a shadow root are retargeted as they cross the boundary, so outside listeners see the host element as event.target rather than the internal node. Custom events also need composed: true to escape the shadow root at all. Built-in UI events like click are already composed.

The takeaway

DOM events travel down through ancestors in the capture phase, fire on the target, then travel back up in the bubble phase. Listeners default to bubbling, which is what makes delegation work; add { capture: true } when you need to see an event first or handle one that doesn’t bubble. Read event.target for where the event started and event.currentTarget for where your listener sits, and reach for stopPropagation() only when you’re sure nothing upstream depends on the event.

Takina Takina · · 4 min read

Object.groupBy() in JavaScript, Explained

Object.groupBy() buckets array items by a key you compute, replacing the reduce() boilerplate developers have written for years. How it works.

#JavaScript #Web Development #Frontend
Takina Takina · · 4 min read

Svelte 5 Runes Explained

Svelte 5 runes like $state and $derived replace the old reactive-assignment magic with explicit function calls that work anywhere in a file.

#JavaScript #Frameworks #Web Development