Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Input Event Pipeline

Concept

Vocabulary that names a phenomenon.

The path an operating-system input event takes from the browser process into the renderer’s compositor thread, where a plain scroll is answered without the main thread, and onward to Blink’s main thread for hit testing and script dispatch only when correctness requires it.

There is a familiar moment on a heavy web page that has otherwise locked up: the buttons will not click and the spinner will not move, yet the scroll itself still glides under the finger. That is the input event pipeline doing its job. Scroll keeps moving because the part of the browser that answers the gesture is not the part that is stuck. Naming the pipeline explains why that split exists and what makes a page forfeit it.

What It Is

An input event in Chromium is not delivered straight to the web page. It travels a route with a deliberate shortcut in the middle.

The route begins in the browser process, which owns the connection to the operating system and receives the raw OS event: a touch down, a wheel tick, a mouse move. The browser process does not run page JavaScript and cannot be the thing that scrolls the page, so it forwards the event across the process boundary to the renderer that owns the targeted content. That crossing is the same trust boundary named by the Browser-Renderer Privilege Split: the privileged browser process hands type and coordinates to the unprivileged renderer.

Inside the renderer the event arrives first not on the main thread but on the compositor thread. WidgetInputHandlerManager receives it and hands it to InputHandlerProxy, a cc::InputHandlerClient that sits in front of the compositor’s LayerTreeHostImpl (which implements the cc::InputHandler interface). This is the shortcut. The compositor thread already holds the active layer tree that Compositor Frame Scheduling draws from, and it can change a scroll offset on that tree directly. For a plain scroll with no script that cares about it, the pipeline ends here: the compositor consumes the gesture, updates the active tree, and produces a frame on the next vsync. The main thread is never told.

The compositor cannot answer everything. When the page has registered a handler for the event in the region under the pointer, or when the event needs hit testing that only Blink can perform correctly, the compositor escalates: it queues the event in the CompositorThreadEventQueue (CTEQ) and posts it to Blink’s main thread, where the Rendering Pipeline’s hit-testing and event-dispatch stages run. The escalation is the expensive path, and most of the pipeline’s performance character is about avoiding it.

Two mechanisms decide whether the compositor must escalate:

  • The non-fast scrollable region (historically called the touchEventHandlerRegion) is the area of the page covered by handlers that might call preventDefault(). The compositor ray-casts the pointer against this region. A hit means the page might want to cancel the scroll, so the compositor cannot scroll on its own; it must round-trip to the main thread and wait for an answer. A broad handler attached high in the document, by event delegation, can mark almost the whole page as a non-fast scrollable region and defeat the fast path everywhere.
  • The passive-listener flag breaks the tie in the page’s favor. A listener registered {passive: true} promises never to call preventDefault(), so the compositor is free to scroll immediately and dispatch the event to the listener afterward. A non-passive listener forfeits that promise and forces the compositor to wait.

When the compositor does send a touch event to the main thread, the answer comes back as an ACK disposition. NO_CONSUMER_EXISTS means no handler region was hit and the event bypasses the compositor to the platform gesture detector. NOT_CONSUMED means a handler exists but did not cancel the event, so the compositor may proceed with the scroll. CONSUMED means a handler called preventDefault(), and the compositor must not scroll. The disposition is the page’s verdict on whether the gesture belongs to the script or to the scroller.

Continuous events get one more treatment. wheel, mousewheel, mousemove, pointermove, and touchmove can arrive faster than the display refreshes, so they are coalesced: the pipeline merges the backlog and dispatches the latest just before the next frame, with getCoalescedEvents() available to a handler that needs the intermediate samples it skipped. Discrete events (keydown, touchstart) dispatch immediately, because there is nothing to merge and latency matters more than batching.

The direction of travel for this part of the architecture is scroll unification, a Chromium project that removes gesture-scroll handling from Blink entirely so that all scrolling runs on the compositor. As of this writing it is an in-progress architectural direction rather than a single shipped milestone; the cc/input/README.md describes its principle, that input “doesn’t have to block on a potentially busy main thread.”

Why It Matters

The pipeline is the reason a busy page can still be scrolled, and naming it converts a vague sense of “Chrome stays responsive” into a specific rule a contributor can enforce.

The headline fact of Main Thread Starvation is that a held main thread freezes scripts and input dispatch but not compositor-driven scroll. That bound isn’t automatic; it’s produced by the compositor-thread fast path in this pipeline. The compositor answers the scroll gesture on the active tree without consulting the main thread, so the main thread’s 300 ms task is not on the scroll’s critical path. Remove the fast path, by marking the page a non-fast scrollable region, and the bound disappears: now the scroll has to wait for the same starved thread as everything else, and the page that used to stay scrollable under load freezes completely.

This is where the pipeline becomes an authoring rule rather than an internal curiosity. A scroll-affecting input handler that does not need to cancel scrolls should be registered passive, because a non-passive handler forces every scroll in its region to round-trip to the main thread before a single pixel moves. A handler that calls preventDefault() forfeits the compositor fast path by definition; it has told the browser that the script owns the gesture. The two handlers can look almost identical in source, one flag apart, and differ by an order of magnitude in responsiveness under load.

For an enterprise reader assessing why a Chromium-based product feels laggy when the machine is busy, the pipeline names the mechanism precisely. The lag is rarely a slow scroller; it’s a broad non-passive handler, often inherited from a third-party widget or analytics SDK, that has quietly marked the scroll region non-fast and put every gesture behind the main thread. The fix isn’t faster hardware. It’s making the handler passive, or narrowing its region, so the compositor can answer the gesture itself.

The pipeline also locates a latency budget. The RAIL Performance Model’s Response window is the target the compositor fast path exists to meet: input that the compositor can answer alone is answered within a frame, while input that must escalate to the main thread inherits whatever the main thread’s queue costs. The split between the two is the split between input that hits the budget and input that gambles on it.

How to Recognize It

The pipeline is observable in a chrome://tracing capture and in the DevTools Performance panel, and it leaves a distinct signature for each of its two paths.

Capture a trace while scrolling a page with no scroll-linked script. The compositor-thread track shows the gesture handled on the impl side, with no main-thread event-dispatch slice between the input and the resulting frame. That structural absence, the missing main-thread hop, is the fast path. It is the same absence that marks an impl-only frame in Compositor Frame Scheduling, seen from the input side rather than the output side.

Now add a non-passive touchstart or wheel listener high in the document and re-capture. The main-thread track grows event-dispatch slices on every scroll frame, and the compositor’s scroll waits on them. DevTools surfaces this directly: the Performance panel flags a scroll blocked by a non-passive event listener, and the Rendering tab’s Scrolling performance issues overlay paints the non-fast scrollable region on the page so the contributor can see exactly how much of the document the handler captured.

The source tree maps the pipeline to specific classes. WidgetInputHandlerManager is the renderer-side entry point that receives the forwarded event. InputHandlerProxy is the compositor-thread input handler that decides fast-path versus escalation. LayerTreeHostImpl is the cc::InputHandler that applies the scroll to the active tree through its ScrollBegin / ScrollUpdate / ScrollEnd methods. The CompositorThreadEventQueue is the staging buffer for events that must cross to the main thread. A regression bisect that lands in cc/input/ or in the Blink widget-input tree is an input-pipeline regression specifically, distinct from a Blink event-dispatch regression on the main thread.

The ACK disposition is visible too. A touchstart that returns NO_CONSUMER_EXISTS shows the compositor proceeding without ever posting to the main thread; a CONSUMED disposition shows the main-thread hop followed by a cancelled scroll. The disposition names which of the three outcomes the page chose.

How It Plays Out

A team ships an enterprise browser fork and reports that scrolling any page feels heavy, but only on machines under load. A trace shows main-thread event-dispatch slices on every scroll frame even on pages with no obvious scroll script. The Rendering overlay paints almost the entire viewport as a non-fast scrollable region. The cause is a global touchstart listener installed by an injected accessibility shim, registered without the passive flag, which marked the whole document non-fast and routed every gesture through the main thread. Adding {passive: true} to the listener restores the compositor fast path; the overlay clears and the scroll decouples from main-thread load.

A games studio embedding a Chromium runtime reports that a custom scroll area stutters when the level streams in. The scroll area uses a wheel handler that calls preventDefault() to implement a zoom gesture. Because the handler can cancel the event, the compositor cannot scroll on its own and must wait for the main thread’s verdict on every wheel tick, and during streaming that thread is busy. The fix is to scope the preventDefault() to the zoom modifier only and register the listener passive for the plain-scroll case, so the common path stays on the compositor and only the zoom gesture pays the escalation.

A productivity application reports that a drawing canvas drops fine-grained strokes during fast motion. The pointermove handler reads event.clientX once per event, but the pipeline coalesces pointermove and dispatches only the latest sample before each frame, so the intermediate positions never reach the handler and the stroke looks jagged. The fix is getCoalescedEvents(), which returns the merged samples the pipeline skipped; the handler reconstructs the full path without forcing the pipeline to dispatch every raw event synchronously. The recognition required knowing that continuous events are coalesced by design, not dropped by accident.

Consequences

Naming the input pipeline buys several operational properties, each paired with its cost.

Scroll responsiveness decouples from main-thread load by default, for the cases the compositor can answer. A plain scroll with no cancelling handler is answered on the compositor thread, so it survives a busy main thread. The decoupling is automatic and invisible, which is also its liability: a page forfeits it silently, with no error and no warning, the moment a broad non-passive handler marks the scroll region non-fast. The contributor inherits the fast path or loses it based on a single listener flag, and nothing in the code review surfaces the loss.

The escalation cost is locatable and attributable. When input must reach the main thread, the trace shows the hop and the ACK disposition shows why. A scroll that waits is a touchstart that hit a handler region, or a wheel whose listener was non-passive, or an event that needed Blink-only hit testing. Each has a different remediation: make the handler passive, narrow its region, or accept the escalation as inherent to the feature. The pipeline turns “the scroll feels heavy” into a specific question with a specific answer.

Coalescing trades fidelity for budget, and the trade is recoverable. Merging continuous events keeps input from flooding the main thread, at the price of intermediate samples a handler may need. getCoalescedEvents() is the recovery valve, so the cost is paid only by handlers that ask for the full stream. A handler that ignores coalescing and reads one sample per dispatched event gets a thinned signal and may not realize the thinning is by design.

The trust crossing is structural and one-directional. Every input event enters through the privileged browser process and is forwarded to the unprivileged renderer; the renderer never reaches back to grab OS events on its own. That arrangement is what lets the browser process arbitrate input routing across renderers and enforce the boundary, and it means the renderer-side pipeline always operates on data the browser process chose to hand it, never on raw OS state.

Notes for Agent Context

When generating a scroll-affecting input handler for Chromium (touchstart, touchmove, wheel, mousewheel), register it with {passive: true} unless the handler genuinely must call preventDefault(). A non-passive listener marks its region a non-fast scrollable region and forces every scroll gesture in that region to round-trip to the main thread before the compositor can move a pixel, so it loses the responsiveness that keeps scroll smooth while the main thread is busy. Never attach a broad non-passive touchstart or wheel handler high in the document by event delegation; it can mark almost the whole page non-fast.

Do not call preventDefault() in a scroll-region handler unless cancelling the scroll is the actual intent. A handler that calls it forfeits the compositor fast path by definition, because it has told the browser the script owns the gesture and the compositor must wait for the main thread’s verdict (the CONSUMED ACK disposition) on every event.

When handling continuous pointer or wheel input that needs every sample (drawing, gesture recognition, physics), call getCoalescedEvents() inside the handler rather than reading a single coordinate per event. The pipeline coalesces continuous events and dispatches only the latest before each frame, so a handler that ignores coalescing silently receives a thinned signal.

Sources

The canonical primary source for the compositor-thread input handler is the Chromium project’s cc/input/README.md, which names InputHandlerProxy as the compositor-thread entry point, LayerTreeHostImpl as the cc::InputHandler it drives, the CompositorThreadEventQueue it stages escalated events in, and the scroll-unification principle that input should not block on a potentially busy main thread. The compositor hit testing design document on chromium.org is the authoritative description of the touch-ACK dispositions (NO_CONSUMER_EXISTS, CONSUMED, NOT_CONSUMED) and the per-layer ray-cast against the handler region. The RenderingNG architecture article by Chris Harrelson and the Chrome rendering team on developer.chrome.com places hit testing and script event dispatch on the main thread and compositor input handling on the compositor thread, framing the two-path structure for a non-specialist reader. Mariko Kosaka’s Inside look at modern web browsers (part 4) on developer.chrome.com is the executive-readable account of the browser-to-renderer forwarding, the non-fast scrollable region created by broad event delegation, the role of {passive: true}, and the coalescing of continuous events with getCoalescedEvents(). The docs/how_cc_works.md document in the source tree connects the input handler to the active-tree scroll offset the compositor draws from.

Technical Drill-Down