EventsGENERALBROWSER-SPECIFICFRAMEWORK-SPECIFIC

How an Event Is Dispatched

Hit-test to a target, build the propagation path, then walk it: capture down, at target, bubble up — with the path frozen before any listener runs.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

When a user clicks, how does the browser decide which code runs, and in what order?

The user intent

Someone points at a thing on screen and acts on it — a tap, a click, a key. They expect the thing they pointed at to respond, and nothing else.

The obvious build

You attach a handler to the element and it runs when the element is clicked. Order does not come up, because there is only one handler.

Why it breaks

There is never only one handler. A modal has an overlay listener, the app has an analytics listener on the root, the router intercepts link clicks, and a framework has one delegated listener for the whole tree — a single click runs all of them, in an order nobody chose deliberately.

How it breaks in a real browser
  • There is never only one handler. A modal has an overlay listener, the app has an analytics listener on the root, the router intercepts link clicks, and a framework has one delegated listener for the whole tree — a single click runs all of them, in an order nobody chose deliberately.
  • The element you attached to is often not event.target. Click the <span> inside a <button> and the target is the span; code that reads event.target.dataset.id gets undefined for a reason that looks like a data bug.
  • Some events do not bubble at all. focus, blur, mouseenter, mouseleave, load and error never reach an ancestor listener, so the delegated handler that works for click silently does nothing for focus.
  • A handler that removes its own element mid-dispatch does not stop the event: the path was computed before the first listener ran, so the remaining listeners on nodes that are no longer in the document still fire.
  • Hit-testing is not "the element under the cursor" in the DOM sense — it is paint order plus stacking plus the CSS pointer-events property. A transparent overlay with z-index steals every click while looking like nothing at all (Positioning and Stacking Contexts).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • The browser hit-tests the input coordinates against the painted, composited result — not against the DOM tree order. Stacking contexts, z-index, transforms and pointer-events all take part, which is why the visually topmost thing is the thing that gets the event (Pointer Events).
  • It then builds the propagation path: the chain of nodes from that target up through its ancestors to the Document and the Window. This happens once, at dispatch, and is frozen. Mutating the DOM inside a listener does not rewrite the path already in flight.
  • It walks the path in three phases. Capture runs from Window down to the target's parent, firing only listeners registered with capture: true. At target fires the target's own listeners, capture and bubble alike, in registration order. Bubble runs from the parent back up to Window, firing non-capture listeners.
  • event.target is the node the event was dispatched at and never changes during the walk. event.currentTarget is the node whose listener is executing right now, and changes at every step. event.eventPhase tells you which of the three you are in.
  • Dispatch is synchronous. dispatchEvent() runs every listener on the path to completion and returns before the next line — all of it on the one thread that also owns rendering (What the Main Thread Owns).
  • Shadow boundaries retarget. An event that crosses out of a shadow root reports the host element as target to outside listeners, so the internals stay encapsulated; composedPath() returns the real path if the event is composed (Shadow DOM and the Composed Tree).
  • After the walk, and only if nothing cancelled it, the browser runs the default action — navigate the link, submit the form, toggle the checkbox, scroll the page (preventDefault vs stopPropagation).

What this makes the browser do

And which of it is avoidable.

  • Hit-testing every input point against the current composited frame, which is why input during a heavy layout can be answered late even though no JavaScript has run yet.
  • Allocating and populating the event object and the path array once per dispatch, then invoking each listener as a JavaScript call on the main thread.
  • Firing the whole pointermove / mousemove stream at input-device rate unless you coalesce it. Movement events are the cheapest place in the browser to accidentally schedule work per pixel (The Frame Budget).
  • Keeping every listener alive as a GC root from its node. Listeners attached and never removed are a common retention path for entire detached subtrees (Memory Leaks).
  • The avoidable part is almost always listener count and handler body, not dispatch itself. Dispatch of one event across a deep path is cheap; a hundred listeners each doing a layout read is not (Layout Thrashing).

From a coordinate to a call stack

A pointer event begins as a position, not as an element. The browser hit-tests that position against the composited frame it most recently produced — which means the answer depends on paint order and stacking, not on where a node sits in the markup. This is why an absolutely positioned sibling with a higher stacking order swallows clicks meant for the element it visually covers, and why an element with CSS pointer-events: none is invisible to hit-testing while remaining perfectly visible on screen.

Once a target node exists, the browser builds the propagation path and walks it. Everything after this point is ordinary synchronous JavaScript on the main thread: a sequence of function calls, one per matching listener, with a single shared event object threading through them.

One click, start to finish
target nodeunless stoppedunless stoppedPointer or key inputHit-test against the composited frameBuild propagation path (frozen here)Capture: Window → Document → … → parentAt target: the node itself, in registration orderBubble: parent → … → Document → WindowDefault action, unless preventDefault() was calledState change → render → pixels
UserLLMAgentToolDataDecisionHumanGuardrail

The walk, step by step

The order below is the whole model. It is worth reading as an algorithm rather than a diagram, because each step has its own way of going wrong, and the failure usually appears at a completely different step from the cause.

Dispatching one event
  1. 1
    Determine the target

    Hit-test a coordinate, or take the focused element for keyboard input, or take the node passed to dispatchEvent().

    fails by An invisible overlay, a pointer-events value you forgot, or nothing focused — in which case key events land on the body.

  2. 2
    Build the path

    Collect target, its ancestors, the Document and the Window into an ordered list, retargeting across any shadow boundaries.

    fails by Nothing at dispatch time — but it fixes the path, so later DOM mutation cannot redirect an event already in flight.

  3. 3
    Capture phase

    Walk from Window down to the target's parent, firing listeners registered with capture: true.

    fails by A capture listener calling stopPropagation(), which cancels the target's own handler before it ever runs.

  4. 4
    At target

    Fire all listeners on the target, capture and bubble flags alike, in the order they were registered.

    fails by Duplicate registration from a re-render, so the same callback runs several times per click.

  5. 5
    Bubble phase

    Walk back up to Window, firing non-capture listeners — the phase delegation depends on.

    fails by A non-bubbling event type, or a descendant that called stopPropagation() for a local reason.

  6. 6
    Default action

    Navigate, submit, toggle, scroll, focus — whatever the platform does for this event on this element.

    fails by A stray preventDefault(), or an event that was never cancelable and quietly ignored the call.

Only two of these six steps involve your listeners. Four are the browser making decisions before and after your code gets a turn.

target, currentTarget and the trace nobody prints

The single most useful debugging habit in this module is to log the phase and both targets at every listener. It converts an argument about ordering into two lines of output, and it makes the frozen-path behaviour immediately obvious: currentTarget marches up the tree while target never moves.

The trace below is what the code produces for a click on a <span> inside a <button> inside a <form>, with a capture listener on the document and bubble listeners on the form and the button.

  • event.target answers "what did the user hit". event.currentTarget answers "whose listener is this". They are equal only at the target.
  • currentTarget is null once the dispatch is over — reading it inside an await continuation or a setTimeout gives you nothing (Tasks: The Unit That Cannot Be Interrupted).
  • composedPath() returns the full path including nodes inside shadow roots, and is the only way to see through retargeting (Shadow DOM and the Composed Tree).
  • event.eventPhase is 1, 2 or 3. If you have never printed it, print it once — it settles most ordering arguments in a single click.
Instrumenting a dispatch
1const trace = (label: string) => (e: Event) => {
2 const phase = ['NONE', 'CAPTURING', 'AT_TARGET', 'BUBBLING'][e.eventPhase]
3 console.log(label, phase, {
4 target: (e.target as Element).localName, // never changes
5 currentTarget: (e.currentTarget as Element | null)?.localName ?? 'window',
6 composed: e.composed, // can it leave a shadow root?
7 isTrusted: e.isTrusted, // user-generated, or dispatchEvent()?
8 })
9}
10
11// capture: true means "see it on the way down"
12document.addEventListener('click', trace('document'), { capture: true })
13form.addEventListener('click', trace('form'))
14button.addEventListener('click', trace('button'))
15
16// One signal removes the whole group — far harder to leak than
17// matching every addEventListener with a removeEventListener.
18const ac = new AbortController()
19button.addEventListener('click', onSave, { signal: ac.signal })
20// later: ac.abort()

The signal option is the part worth stealing. Cleanup is where listener bugs actually come from, and an AbortController turns n removals into one.

click on <span> inside <button type="submit"> inside <form>

document  CAPTURING  target: span   currentTarget: #document
button    AT_TARGET  target: span   currentTarget: button
form      BUBBLING   target: span   currentTarget: form
→ default action: submit the form

Read it twice:
  target        is the span, in every single line
  currentTarget is the node whose listener is running
  the button listener fires AT_TARGET, not "bubbling"

How to build it

Most important first.

  • Read event.currentTarget when you mean "the element I attached to" and event.target when you mean "the deepest thing the user actually hit". Nearly every target/currentTarget bug is this sentence, unwritten.
  • Use closest() to walk from the target back to the element you care about, rather than assuming the target is it (Event Delegation).
  • Register on the capture phase only when you genuinely need to see the event before descendants — an outside-click dismisser, a global shortcut guard, an instrumentation hook. Capture is a strong claim and it makes ordering harder to reason about.
  • Prefer the bubbling twin for events that do not bubble: focusin/focusout instead of focus/blur, or register focus with capture: true if you must.
  • Do not encode ordering assumptions between listeners on different nodes. If two things must happen in order, that is one handler calling two functions, not two handlers hoping.
  • Use AbortController with a single signal to remove a group of listeners at once — it is far harder to leak than matching every addEventListener with a hand-written removeEventListener.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Keyboard-generated events do not come from a coordinate — they are dispatched at document.activeElement. A control that is not focusable receives no key events at all, which is why hit-testing intuition does not transfer (Keyboard Operability).
  • Assistive technology activation of a control produces a real, trusted click event with no preceding pointer events. Logic that requires a pointerdown before it will accept a click locks out screen-reader and switch users (Keyboard Events).
  • A capture-phase listener on document that calls stopPropagation() can swallow the Escape and Tab handling of a dialog underneath it, breaking focus management for everyone but visibly for nobody (Focus Management).
  • Because dispatch is synchronous on the main thread, a slow handler delays focus moves and live-region announcements exactly as it delays paint (Live Regions and Announcement).

What can go wrong

Failure modes
  • A transparent full-viewport overlay left mounted with opacity: 0. Every click in the app hits it and nothing responds; the DOM looks correct and the CSS looks harmless.
  • A listener added on every render and never removed. Handlers pile up, the same click runs the callback n times, and n grows with how long the user has been on the page.
  • A delegated root listener and a local listener both handling the same click, in an order that depends on where each was registered — so the behaviour changes when someone reorders imports.
  • Assuming the path reflects the live DOM. A handler that unmounts a panel leaves later handlers running against detached nodes, where getBoundingClientRect() returns zeros and closest() walks a tree no longer attached to the document.
  • Relying on capture to "win". stopPropagation() in a capture listener prevents the target's own handler from ever running, which is a very effective way to break a component you do not own (preventDefault vs stopPropagation).
What can arrive out of order
  • A listener that mutates the DOM changes what later listeners on the same dispatch observe, while the path itself does not change — so contains() checks inside a bubble handler can disagree with the path it is travelling.
  • Two independent listeners on the same node fire in registration order, which for lazily-loaded modules is load order, which is not stable across builds (Code Splitting).
  • An await inside a handler ends the synchronous dispatch for that callback: the rest of the path runs, the default action is decided, and your continuation resumes afterwards with the DOM already changed (The Microtask Checkpoint).
Security
  • event.isTrusted distinguishes an event the user agent generated from one your script synthesised with dispatchEvent(). The browser enforces this flag; script cannot forge it.
  • A number of privileged actions — opening a window, entering fullscreen, reading the clipboard, starting playback with sound — require transient user activation, which only a trusted event grants and which expires. Synthetic clicks do not unlock them.
  • Events do not cross origins. A listener in your page cannot observe input inside a cross-origin <iframe>; conversely, an iframe you embed cannot read your key events (The Same-Origin Policy).
  • That guarantee is exactly what clickjacking attacks: the user's click is trusted and genuine, but aimed at a frame they cannot see (Clickjacking and Framing).
  • Any script in your page can attach a capture listener on document and observe every keystroke and click in it. Third-party script is not partially trusted (Third-Party Scripts and the Supply Chain).
Misreads
  • "The event starts at the element and goes up." It starts at the Window going *down*. Capture happens first, every time, and it is where an outside-click handler gets to see the click before the component that will cancel it.
  • "event.target is the element I attached the listener to." That is currentTarget. target is wherever the user actually hit, which is usually a text node's parent inside your component.
  • "If I remove the element, the event stops." The path was frozen at dispatch. Removal changes what the DOM looks like, not where the event is already going.
  • "Capture and bubble are two different events." One dispatch, one event object, three phases. stopPropagation() in the capture phase kills the bubble phase too, because there is nothing left to travel.
  • "React events are DOM events." They are synthetic events over a delegated root listener. Native stopPropagation() on a node inside that root prevents the React handler from ever seeing it (The React Mental Model).

Measuring it, and what changes in the field

How you would see this
  • The Elements panel's Event Listeners pane lists every listener on the selected node and its ancestors, with the registering source and whether it is capture or passive — the fastest answer to "what else runs on this click".
  • In the Chrome console, getEventListeners($0) dumps the listeners on the inspected node and monitorEvents($0, 'click') logs dispatches live. Both are devtools-only helpers, not page APIs.
  • The Performance panel's interaction records show the input, the handlers that ran and the frame that followed, which is where "the click was handled, the pixel was late" becomes visible (Interaction Responsiveness).
  • Growing listener counts across repeated navigations, visible in a heap snapshot's retained detached nodes, are the signature of listeners that were added and never removed (Debugging Memory).
Slow device, slow network, large data, old tab
  • On a slow device the gap between the input and the handler widens, because hit-testing waits for a composited frame and the handler waits for the main thread to be free (Long Tasks).
  • On a deep tree — a design system nesting a dozen wrappers around a button — the propagation path is long, so a root-level delegated listener is doing more traversal per event than the source suggests.
  • On a page with thousands of interactive rows, per-row listeners multiply memory and registration cost even though each individual dispatch stays cheap (List Virtualization).
  • In a long-lived single-page app, listener leaks accumulate across routes rather than being cleared by navigation, so the same click gets progressively more expensive over a session (Long-Lived Clients and Version Skew).
What this costs
  • Understanding phases costs a genuinely non-obvious mental model — most developers ship for years knowing only "it bubbles". The payback is that ordering bugs stop being mysteries and become a question you can answer by looking at one panel.
  • Capture-phase listeners give you first refusal on an event, at the cost of making the ordering of an unfamiliar codebase harder to predict, especially for the next person debugging it.
  • Removing listeners properly means threading an AbortController or a cleanup function through component lifecycles — more plumbing than attaching and forgetting, and the only version that does not leak.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe three phases, the frozen propagation path, target versus currentTarget and shadow retargeting are DOM specification behaviour and match across Blink, Gecko and WebKit; disagreements between engines here are bugs, not variation.
  • BROWSER-SPECIFICThe inspection tooling is not portable: getEventListeners and monitorEvents are Chromium console helpers, Firefox exposes the same information through the event badge next to a node in the Inspector, and Safari lists listeners in the Node pane with less ancestor detail.
  • FRAMEWORK-SPECIFICReact attaches its listeners at the root container of the tree rather than on each element, so the node a native listener sits on decides whether it runs before or after a React handler; Vue, Svelte, Solid and Angular attach to the real element and do not have this ordering surprise.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Performanceevent-loop-lag
Domains that do not exist yet
  • Programming Languages & Runtime Internals — what a listener closure actually retains, and why a handler capturing one node can keep an entire detached subtree reachable.