From a click to our listener
The previous page turned HTML into pixels. Now someone clicks those pixels. We follow one press of a mouse button from the USB report and the Linux kernel, through Chrome's browser process, the renderer's compositor and main threads, the hit test that decides which element was under the pointer, and the DOM's capture-target-bubble dispatch, into our addEventListener callback, and then into the microtasks that run right after it.
Browser track: 1 · What fetch() really sends · 2 · From HTML bytes to pixels · 3 · From a click to our listener
How this was checked: the dispatch model gives the same listener order, phases, microtask placement and defaultPrevented as Chrome 141 on 1,609 distinct programs (799 of them clicked for real through the browser's input path), and the hit-test model picks the same element as Chrome's elementFromPoint at 40,320 points in 1,008 scenes. The trace and the listener logs are real captures from headless Chromium 141; they are in the folder's data/ directory with the script that made them. Chromium excerpts are from upstream (October 2026) and Linux excerpts from the mainline kernel, trimmed with // ….
A button closes a circuit, and the kernel writes 24 bytes
A USB mouse in boot protocol sends a short report every time something changes. Byte 0 holds the buttons, one bit each; bytes 1 and 2 are the X and Y movement. The kernel's driver gets the report in an interrupt handler and turns each bit into a call to input_report_key. Then input_sync marks the end of one consistent report.
The evdev handler copies each of those values into a struct input_event in a per-reader ring buffer and wakes anyone reading /dev/input/eventN. On desktop Linux the reader is the window system (a Wayland compositor through libinput, or the X server), which sends Chrome a pointer-button message for its window. On ChromeOS, Chrome reads evdev itself; that is the EventConverterEvdevImpl code below, the same input_event structs going into Chrome's ui::Event system.
Followed by a second 24-byte event with type EV_SYN (0), code SYN_REPORT (0), value 0: "this report is complete". Little-endian, so 0x0110 is stored as 10 01.
Nothing at this layer knows about pages or elements. It knows a button changed state at a time, and where the cursor is in screen coordinates. Everything after this is Chrome deciding which part of which page that position means.
Route it to the right renderer, send it, and wait for the answer
The window system's event reaches Chrome's browser process as a ui::MouseEvent on the window (Aura on Linux and Windows). RenderWidgetHostViewEventHandler::OnMouseEvent converts it to a blink::WebMouseEvent and asks the input event router where it should go. With site isolation a page can contain iframes from other sites, each drawn by a different renderer process, so the router first hit-tests surfaces: the regions each renderer's frames cover, as reported to the GPU process (Viz). With only one renderer on screen it skips that work, as the code shows.
Then RenderWidgetHostImpl::ForwardMouseEvent hands it to the input router, which sends it over Mojo with WidgetInputHandler::DispatchEvent. A press is blocking: the browser keeps it in flight until the renderer replies with whether a listener consumed it. That reply is how the browser knows, for example, whether to start a drag or show a context menu.
In our trace, the browser's ForwardMouseEvent and the Mojo send take well under a tenth of a millisecond, and the renderer's compositor thread picks the message up a few tenths of a millisecond later. The answer comes back as InputRouterImpl::MouseEventHandled after the main thread has run every listener: the browser really does wait for our JavaScript.
The compositor thread sees it first, and a press does not wait for a frame
Mojo delivers DispatchEvent on the renderer's compositor thread, not the main thread. That thread can handle some input alone: it can scroll a layer while the main thread is busy, unless a non-passive wheel or touch listener might cancel the scroll. A mouse press is not something it can handle, so MainThreadEventQueue queues it for CrRendererMain.
The queue treats event types differently. Mouse moves, wheel and touch moves are rAF-aligned: they are held and coalesced until the next frame begins, so a mouse that reports 1,000 times a second does not run our mousemove listener 1,000 times. A press or release is not in that list, so it is posted to the main thread right away.
This is the same main thread as the event-loop page. The input arrives as a task on CrRendererMain. If a long task is running, our click waits behind it, and the browser's acknowledgement waits too. That waiting is part of what the INP (Interaction to Next Paint) metric measures.
Which element is under the pointer? Walk the painting order backwards
EventHandler::HandleMousePressEvent asks for the target with GetMouseEventTarget, which ends in LayoutView::HitTest. The question "what is under this point?" must give the answer we see, so hit testing follows the same order as painting, in reverse: whatever was painted last is on top and is tested first.
PaintLayer::HitTestLayer does this per stacking context: first the children with positive z-index (highest first), then the normal-flow and z-index: 0/auto children, then the layer's own foreground, then negative z-index children, then its own background. A box is skipped when the point is outside it, when an ancestor's overflow clips it there, or when it has pointer-events: none or visibility: hidden. Both of those are inherited, and a descendant can set them back.
In the real trace, the press's HitTest event reports nodeName: #text: the pointer was over the letters of "Save". Events are not fired at text nodes, so Blink uses the nearest element, the <button>. Hit testing also updates :hover and :active, which is why it runs even when no listener exists.
Click the stage: the model and this browser each say what we hit
Each line is one absolutely positioned box: box ID LEFT TOP WIDTH HEIGHT, then optional in PARENT, z=N, pe=none or pe=auto, hidden or visible, and clip (overflow: hidden). The stage is a 360 × 240 stacking context. Click anywhere on it, or type a point and press Test point. The model walks its painting order from the front; this browser answers with document.elementFromPoint on the very same boxes.
What it is. Our hit tester: the CSS painting order for positioned boxes walked backwards, with stacking contexts from z-index, overflow clipping, and inherited pointer-events and visibility.
Checked against. Chrome 141's elementFromPoint at 40,320 points in 1,008 scenes (8 presets and 1,000 random scenes, 40 whole-pixel points each), all identical (verify/compare-hit-test.js). Live: every click on the stage asks your browser too.
Not covered. Transforms, opacity, filters and other properties that also create stacking contexts; in-flow (non-positioned) content and text; rounded corners; scrollbars; iframes; and how a fractional point is rounded, so the lab and the check use whole pixels.
Painting order, back to front
Hit test: front to back, first one that qualifies
One press gives two events; one release gives three
For a press, Blink fires a pointerdown (Pointer Events) and then the compatibility mousedown, both at the hit element. If the element can take focus, focus moves next (focus, focusin). For a release it fires pointerup, mouseup, and then possibly a click.
The click is not simply "the element under the release". MouseEventManager::DispatchMouseClickIfNeeded takes the element that got the mousedown and the element under the mouseup and sends the click to their common ancestor. We captured that: press on #a, drag to #b, release, and the click landed on #card, which contains both. That is why dragging off a button before releasing does not click it.
Capture down, target, bubble up: EventDispatcher::Dispatch
Dispatch starts by building the event path: the target, each ancestor, the document and the window. It is fixed now; moving the target to another place during dispatch does not change who is visited. Then DispatchEventAtCapturing walks the path from the window down and runs capture listeners; at the target it runs only the target's capture listeners. DispatchEventAtBubbling walks back up: the target's other listeners first, then, if the event bubbles, each ancestor's non-capture listeners.
At each target, FireEventListeners takes a snapshot of that target's listener list. A listener added now does not run in this pass. A listener removed now is skipped, because it is marked removed. A once listener is removed just before it runs. stopPropagation() lets the rest of the current target's listeners run; stopImmediatePropagation() stops at once. A passive listener's preventDefault() is ignored.
At the target, capture listeners run before the others whatever order we registered them in. Chrome changed this in version 89; before that, listeners on the target ran in registration order. The comment "To match Mozilla, the AT_TARGET phase fires both…" in FireEventListeners still describes the old behaviour. The "Capture runs first at the target" preset below shows the current order, and the check confirms it against Chrome 141.
Write listeners, dispatch an event, see exactly what runs
A program is a tree of tags with ids, some listen lines, and one dispatch line. Listener options: capture, once, passive. Actions after a colon: stop, stopNow (stopImmediatePropagation), prevent, micro (queue a microtask), add TARGET TYPE [capture], remove L2. Listeners are named L1, L2, … in order, and every listener logs itself when it runs.
dispatch click at btn by user is a real click. by script is dispatchEvent from a script; add nobubble or nocancel for an event that does not bubble or cannot be cancelled. Check in this browser runs the same program with real listeners: a script dispatch runs immediately, and for a user program we click the dashed box ourselves.
What it is. Our implementation of the DOM standard's dispatch as Blink's EventDispatcher and FireEventListeners run it: the path, the three phases, listener snapshots, once, passive, stopping, adding and removing during dispatch, and where the microtask checkpoint falls.
Checked against. Chrome 141 on 1,609 distinct programs (10 presets and 1,599 random, from four seeded batches), with identical logs and defaultPrevented; 799 were clicked for real through the browser's input path and 810 were script dispatches (verify/compare-dispatch.js). Live: Check in this browser.
Not covered. Shadow DOM and retargeting, relatedTarget, default actions (a link navigating, a checkbox toggling), listeners that throw, handleEvent objects, AbortSignal, and the extra events a real click also fires (pointerdown, mousedown, …), which these programs do not listen to.
Event path (fixed before the first listener)
What ran, in order
Why
Into V8, and why microtasks run between the listeners of a real click
A listener is called through JSBasedEventListener::Invoke, which, through the generated callback bindings, ends in V8ScriptRunner::CallFunction. That function opens a V8RunMicrotasksScope around the call. When the scope closes, V8 performs a microtask checkpoint, but only if no other script is still running underneath: ShouldPerformCheckpoint requires the scope depth to be back at zero.
For a real click, the listener was called from C++ input handling with an empty JavaScript stack, so the depth is zero and the microtasks queued by that listener run before the next listener. For btn.click() our own script is still on the stack, the depth stays above zero, and all the microtasks wait until that script finishes. The HTML standard says the same thing in "clean up after running script".
The capture script hit a real bug in its own first version, and it teaches the same lesson. Its microtasks read event.currentTarget. In the real click they ran during dispatch and saw the right element; after btn.click() they ran after dispatch had finished, when currentTarget is already null, and threw. Code that works for a real click can break for a scripted one.
One click in a real Chrome trace, from the browser process to our listeners
We recorded a trace while Playwright pressed and released the mouse over the button. Playwright's mouse goes into Chrome through the DevTools protocol and enters the normal input path at the browser process's RenderWidgetHostImpl::ForwardMouseEvent, the same place an event from the window system arrives. Everything below is a real trace event.
What it is. Trace events from headless Chromium 141 on Linux (data/trace.json, which opens in DevTools or Perfetto), filtered to the input path by data/flow.js.
Checked against. It is the real system. Recapturing gives the same events in the same order; the times vary by machine.
Not covered. The kernel and window system (Playwright enters at the browser process), and a GPU-accelerated desktop: this ran headless, so frame and input timings are only indicative.
| ms | thread | event | took | details |
|---|
Predict, then check
See it on our own machine
| What to see | How |
|---|---|
| Raw kernel events | On Linux: sudo evtest, pick the mouse, click. Each line is one input_event: type 1 (EV_KEY), code 272 (BTN_LEFT), value 1, then a SYN_REPORT. (272 is 0x110.) |
| The listeners on an element | DevTools → Elements → Event Listeners tab: every listener on the node and its ancestors, with capture, passive and once flags. In the console, getEventListeners(el) gives the same. |
| Every event that fires | In the console, monitorEvents(document.body, ["mouse", "pointer"]), then click. unmonitorEvents(document.body) to stop. |
| The input path in a trace | DevTools → Performance → record a click: the Interactions track shows input delay, processing and presentation. Our raw trace is data/trace.json; load it there or in Perfetto. |
| Hit testing by hand | document.elementFromPoint(x, y) and document.elementsFromPoint(x, y), which lists everything under the point, front to back. |
Exercises
- In the dispatch lab, write a program where a capture listener on the window calls
stop. Which listeners still run? Then try it by user and press Check. - Build a hit-test scene where a
pe=noneoverlay covers a button, but a hole in the overlay withpe=autocatches clicks. Which box does a click on the hole hit, and why? - Add
listen window click capture: microto the "Microtasks: a real click" preset. Predict where its microtask appears, check it by clicking for real, then changeby usertoby scriptand predict again. - Record a Performance trace while clicking a button whose listener runs a 200 ms loop. Find the input delay of the next click made during that loop.
- Read
data/flow.json. How long did the press spend between the browser'sForwardMouseEventandMouseEventHandled, and how much of that was our listeners?
Exam-style questions, with model answers
Five questions in the style of a web systems or operating systems exam. Write our answer first, then open the model answer and mark ourselves honestly. Answers that list listener orders were produced by the dispatch model and checked with the lab's real-browser check.
<div id=card><div id=row><button id=btn></button></div></div>. Listeners, in order: L1 card capture; L2 btn, which calls stopPropagation(); L3 btn; L4 row; L5 card. The user clicks the button. List what runs, with each phase, and explain why L3 runs but L4 does not.L1 capture card L2 target btn (stopPropagation) L3 target btn
The capture pass walks window → document → html → body → card → row → btn and runs card's capture listener (L1). At the target, the bubbling pass runs btn's non-capture listeners in order. stopPropagation() in L2 sets a flag that is checked after the current target's listeners finish, so L3 still runs. The loop in DispatchEventAtBubbling then returns, so row (L4) and card (L5) never run. With stopImmediatePropagation(), L3 would not run either.
Marking: 2 for the order, 1 for the phases, 2 for "the flag is checked between targets", 1 for contrasting stopImmediatePropagation.
btn.click() called from a script it runs after both. Name the mechanism in Blink and V8.Each listener is called through V8ScriptRunner::CallFunction, which opens a V8RunMicrotasksScope. When a scope closes, V8 performs a checkpoint only if ShouldPerformCheckpoint is true, which requires the microtask scope depth to be zero (no script still running). A real click is dispatched from C++ input handling, so after each listener the depth is zero and the queued microtasks run immediately. With btn.click(), our script's own scope is still open, so the depth is at least one during the whole dispatch; the checkpoint happens when our script's scope closes, after click() returns. The HTML standard's "clean up after running script" states the same rule.
Marking: 1 for the scope, 2 for the depth-zero condition, 1 for each case.
a at (20, 20) size 200 × 160 with z-index: 1; inside it a1 at (20, 20) relative to a, size 140 × 80, z-index: 100; and b at (120, 60) size 200 × 160 with z-index: 2. Which element does a click at (150, 90) hit? What changes if a has no z-index?The point (150, 90) is inside a (20–220, 20–180), inside a1 (40–180, 40–120) and inside b (120–320, 60–220). With z-index: 1, a is a stacking context, so a1's 100 only orders it inside a. The stage paints a's whole group at level 1, then b at level 2. Backwards: b is first and contains the point, so it is the hit.
Without a z-index, a does not form a stacking context, so a1 takes part in the stage's order at level 100, above b. The hit becomes a1. (Both are the lab's presets; the model and elementFromPoint agree.)
Marking: 1 for the containment check, 2 for the stacking-context argument, 2 for the second case.
mousemove listener.MainThreadEventQueue::IsRafAlignedEvent returns true for mouse moves, wheel and touch moves. Those events are held until the next frame and coalesced, so a 1,000 Hz mouse produces about one mousemove dispatch per frame (a pointermove event's getCoalescedEvents() returns the reports that were merged). Presses and releases are discrete and their order matters, so they are posted to the main thread immediately. A mousemove listener therefore runs about once per frame, just before rendering, and should not expect one call per hardware report.
Marking: 1 for naming the queue, 1 for coalescing, 1 for why presses are not delayed, 1 for the consequence.
Kernel: usb_mouse_irq reads bit 0 of byte 0 and calls input_report_key(dev, BTN_LEFT, 1); evdev stores a struct input_event (EV_KEY, BTN_LEFT, 1) followed by SYN_REPORT. Browser process: the window system's event becomes a ui::MouseEvent; RenderWidgetHostImpl::ForwardMouseEvent sends a WebMouseEvent through InputRouterImpl as a blocking Mojo DispatchEvent. Compositor thread: WidgetInputHandlerImpl::DispatchEvent, then MainThreadEventQueue queues it for the main thread (not rAF-aligned). Main thread: WidgetBaseInputHandler::HandleInputEvent → EventHandler::HandleMousePressEvent → hit test through LayoutView::HitTest. Dispatch: EventDispatcher::Dispatch fires pointerdown; the first capture listener on the path is called by FireEventListeners.
Marking: 1 per layer with a correct name.
Everything this page cites, by depth
The numbered markers in the text open the relevant reading right where we need it. Here is all of it in one place: Start here for a first pass, Go deeper for the full treatment, and Primary sources for the specifications and kernel documentation the code implements.
Original files and data
- linux: drivers/hid/usbhid/usbmouse.c
- linux: include/uapi/linux/input.h
- ui/events/ozone/evdev/event_converter_evdev_impl.cc
- content/…/render_widget_host_view_event_handler.cc
- components/input/render_widget_host_input_event_router.cc
- content/…/render_widget_host_impl.cc
- components/input/input_router_impl.cc
- blink/…/widget/input/main_thread_event_queue.cc
- blink/…/widget/input/widget_base_input_handler.cc
- blink/renderer/core/input/event_handler.cc
- blink/renderer/core/paint/paint_layer.cc
- blink/renderer/core/input/mouse_event_manager.cc
- blink/renderer/core/dom/events/event_dispatcher.cc
- blink/renderer/core/dom/events/event_target.cc
- blink/renderer/bindings/core/v8/v8_script_runner.cc
- v8: src/api/api.cc
- v8: src/execution/microtask-queue.cc
- Our trace, logs, test page and capture script (data/)
Chromium and V8 code is used under their BSD-style licenses and Linux code under GPL-2.0, all quoted for study. Blink paths are relative to third_party/blink/renderer/.