Web Platform · DOM and Events

Event Propagation: The Path, the Three Phases, and the Stop Flags

Dispatch walks a path computed before any listener runs, invoking capture listeners downward and bubble listeners upward, with separate flags for stopping traversal and cancelling the default action.

A click on a button inside four nested elements does not send the browser looking for listeners. It builds a list of every node between the button and the window, then walks that list twice, asking each entry whether it has a listener that matches the current pass. Ancestor listeners fire because their nodes are on the list, not because anything was searched for. This distinction explains most of the behaviour that seems arbitrary until the algorithm is laid out: why a listener added to an ancestor during dispatch still runs, why removing an element mid-dispatch does not skip its parent, and why eventPhase reads AT_TARGET inside a capture listener registered on the target itself.

The path is fixed before the first listener runs

Dispatch begins with path construction. Starting at the event’s target, the algorithm appends each ancestor, following the parent chain, then the shadow-including variants of that chain when the event is composed, and finally the window object if the target is connected to a document. Each path entry records the node that will be invoked, the target as that node is allowed to see it, and related bookkeeping for shadow roots.

Two properties follow. First, the path is a snapshot. A listener that removes its own element, or moves it to another parent, does not change the set of nodes that will be visited for the event already in flight. Second, the path is not the same as the ancestor chain of event.target: shadow boundaries rewrite what each listener sees, which is covered in shadow DOM boundaries and event retargeting. For an event that bubbles out of a component, the entry invoked on an outside ancestor carries the host as its target rather than the internal node.

Two passes over one list

eventPhase takes the values NONE (0), CAPTURING_PHASE (1), AT_TARGET (2), and BUBBLING_PHASE (3). The dispatch algorithm uses only two traversals:

  1. A capture pass from the last path entry toward the target, target included. Each entry is invoked in capture mode, and listeners registered with capture: true run. The target’s entry is marked AT_TARGET, as is the entry for a shadow host whose boundary the path crosses; every other entry is marked CAPTURING_PHASE.
  2. A second pass in the opposite direction, from the target back out to the window. The target’s entry is invoked again, this time in bubble mode, still as AT_TARGET, and host entries behave the same way. Every remaining entry is invoked in bubble mode and marked BUBBLING_PHASE, but only if the event’s bubbles attribute is true; a non-bubbling event stops after the target’s second invocation.

The capture pass runs whether or not the event bubbles, because it is not gated on bubbles at all. An event constructed with bubbles: false still reaches ancestors during capture, then stops at the target. This is the mechanism behind the common trick of putting a single capture listener at the document to observe events that do not bubble.

Because the target is visited by both passes, capture listeners on the target run before bubble listeners on the same target regardless of registration order. On every other node, the two groups run in different passes entirely and never interleave. Within one group on one node, listeners run in the order they were added.

Where the shared listener list lives

Listeners are stored on the EventTarget in a single ordered list. The type and the capture flag are properties of each entry rather than of separate per-type lists, so both passes read the same list and filter by flag. That arrangement is why removeEventListener must be given the same capture value used at registration: the pair of type and capture flag plus callback identity is what locates the entry. Listener registration and options works through the identity rules; propagation only cares that a filtered scan happens.

The three stop flags and what each one ends

Dispatch exposes three distinct controls, and conflating them is the source of most propagation bugs.

stopPropagation() sets a flag that is checked after the current listener returns. The remaining listeners on the same node still run, because the check happens at the boundary between nodes. This is deliberate: a node’s listeners are treated as a unit, and one handler cannot silence its siblings on the same node.

stopImmediatePropagation() sets both the propagation flag and an immediate flag. The immediate flag is checked after each individual listener, so the rest of the listeners on the current node are skipped as well.

preventDefault() sets the cancelled flag when the event is cancelable and the listener was not registered as passive. It has no effect on traversal: other listeners still run, including ones on ancestors. The flag is readable afterwards as event.defaultPrevented. cancelable is fixed at construction; calling preventDefault on a non-cancelable event is silently ignored, which is why some synthetic events appear immune to cancellation.

const outer = document.querySelector('.outer');
const button = document.querySelector('button');

outer.addEventListener('click', () => console.log('outer capture'), true);

button.addEventListener('click', (e) => {
  console.log('first button listener, phase', e.eventPhase); // 2
  e.stopImmediatePropagation();
}, { capture: true });

button.addEventListener('click', () => console.log('never runs'));

button.click(); // outer capture, first button listener

The phase logged above is 2, not 1, even though the listener was registered for capture. At the target there is no downward direction left to distinguish, so the standard reports the target phase for both passes.

Errors inside a listener do not abort dispatch

When a listener throws, the exception is reported to the global error reporting machinery and dispatch continues with the next entry in the path. A throwing handler therefore does not prevent the default action from being cancelled elsewhere, nor does it stop unrelated listeners. The corollary is that a failure in one handler is invisible to the code that called dispatchEvent; dispatchEvent returns the negation of the cancelled flag and says nothing about exceptions.

Reentrancy is permitted. A listener may dispatch a second event, including one of the same type, and that dispatch builds its own path and runs to completion before the outer dispatch resumes. Recursive dispatch on the same target is how infinite loops form, and nothing in the platform caps the recursion.

The window is the last entry on the path

When the path reaches the document, which requires a connected target and composition that does not stop at a shadow boundary, the window object is appended as its outermost entry. That is why a click listener on window receives clicks on any element in the document without any capture flag, and why a non-bubbling event still reaches window during the capture pass. Events dispatched on a detached subtree never get there: the path stops at the highest node in the detached tree, so a component under construction can dispatch freely without leaking events to global handlers.

The added entry also has a cost. Each entry on the path clones its own listener list before iterating and filters it by type, so dispatch cost grows with the depth of the path multiplied by the size of each node’s listener list, not with the total number of listeners alone. A document with deep nesting and a few listeners at every level does more work per event than a flat one with the same total.

Dispatch is synchronous

dispatchEvent runs the entire traversal before returning; there is no queueing. Microtasks queued by a listener run only after the current task’s JavaScript finishes, which for a click handler means after the browser has completed dispatch and any remaining listener work. Two consequences matter in practice. A promise callback cannot run between the capture and bubble passes. And an event dispatched from a MutationObserver callback runs at a microtask checkpoint, after the script that caused the mutation, so its propagation is unrelated to the mutation that triggered the observer, as described in mutation observer records and delivery.

Default actions are a separate mechanism

After dispatch finishes, the browser runs whatever default action the event implies: following a link, submitting a form, focusing an element, scrolling. This step is not a phase and has no listener-visible stage. It consults only the cancelled flag. So an event can be stopped from propagating and still perform its default action, and it can propagate everywhere and still be cancelled at the last moment. UI events additionally carry an isTrusted value that is true only for events generated by the browser itself; script-generated events propagate normally but do not trigger most default actions, because those actions are tied to the browser’s own input handling rather than to the event object.

One last detail concerns currentTarget: it is set to the node being invoked and reset to null when dispatch finishes. Code that stashes the event for later inspection finds target intact and currentTarget empty. That asymmetry is the reliable way to tell whether a handler is running inside dispatch or looking at a retained event object afterwards.

References

  1. DOM Standard: Events — WHATWG
  2. UI Events — W3C
  3. Document Object Model (DOM) Level 2 Events Specification — W3C