How input reaches your event handlers
Learn how browser input reaches event handlers, why passive listeners protect scrolling, and how hit testing and pointer samples work.
- 01Explain scroll routingDescribe when the compositor can start a scroll and when it must wait for the main thread.
- 02Choose listener optionsUse passive listeners honestly and check whether an event is cancelable before relying on preventDefault.
- 03Handle precise inputFind targets with hit testing and feature-detect coalesced and predicted pointer samples.
One input, several decisions
When a finger moves, a wheel turns, or a mouse clicks, the browser first receives coordinates and an event type. It must decide whether it can update the screen quickly, whether JavaScript must get a chance to cancel something, and which page element should receive the event.
The input pipeline is the browser work that routes physical input to a page, finds a target, runs listeners when needed, and performs default actions such as scrolling.
This lesson builds on the rendering pipeline. Rendering prepared layers lets a compositor assemble frames quickly. Input asks whether that same compositor can keep moving a scroll, or whether it must wait for the renderer main thread.
The words in this lesson describe a browser design, not an extra JavaScript event loop. Your handler still runs on the main thread. The useful question is whether the browser must wait for that handler before starting a default action.
Input handled on the compositor thread
The compositor thread combines already-painted layers into frames. For a scroll it can often move those layers without asking the main thread to run JavaScript or recalculate layout. That separation is one reason a page can keep scrolling while other work is busy.
window.addEventListener("wheel", () => console.log("wheel seen"), { passive: true,});console.log("listener added");Line 1 registers a listener on window. Line 2 marks it passive, which is a promise that it will not cancel the wheel event. Line 4 runs immediately and prints listener added. Later wheel input prints wheel seen in the console.
The listener is still useful for observation, analytics, or updating a small visual indicator. Passive does not mean skipped. It means the browser may start the default scroll without waiting to learn whether this particular listener called preventDefault().
A security guard can wave cars through a gate at once. At a gate where the office said “call me before letting anyone in”, every car waits while the office answers. A passive listener is telling the guard there is no need to call.
- In real life: Guard waves cars through
- In JavaScript: Compositor starts a scroll
- In real life: Office may stop a car
- In JavaScript: Main thread may cancel a default action
- In real life: Guard needs no call
- In JavaScript: A passive listener cannot cancel
- In real life: Cars wait for the office
- In JavaScript: A non-fast region waits for JavaScript
Where the analogy stops: Browsers use detailed event and rendering rules; the guard is only a model for the wait-or-proceed decision.
Non-fast scrollable regions
A non-fast scrollable region is an area where a potentially canceling input listener means the compositor may need main-thread confirmation before it starts scrolling. Chrome records this information while it builds and composites the page.
const listeners = [ { target: "cart", type: "wheel", passive: false }, { target: "menu", type: "touchmove", passive: true }, { target: "map", type: "pointermove", passive: false },]; const regions = markScrollableRegions(listeners);console.log(regions.map((region) => region.region).join(","));Line 2 gives cart a non-passive wheel listener, so the model marks it non-fast. Line 3 gives menu a passive touch listener, so it is fast. Line 4 is a pointer listener: it can run JavaScript, but it is not one of this small model's scroll-blocking listener types.
The result prints non-fast,fast,fast. This is a teaching model, not Chrome's region map. It makes one useful rule visible: non-passive wheel, touchstart, and touchmove listeners are the usual listeners that can require a scroll-start decision.
non-fastwheel may cancel
fasttouchmove cannot hold up scroll in this model
fastpointermove cannot hold up scroll in this model
A wheel, touch, or pointer listener that could call preventDefault() changes the decision. The browser cannot know your function will be quick, and it cannot know it will decide not to cancel until it runs. A busy main thread therefore becomes visible as scroll-start delay.
Replay one wheel event through a small, deterministic scroll-start model.
script
const mainThreadBusyMs = 40; function startScroll(passive, mainThreadBusyMs) { return passive ? 0 : mainThreadBusyMs;} console.log(startScroll(passive, mainThreadBusyMs));The replay uses a tiny deterministic model. It does not measure Chrome, but it makes the dependency plain: a non-passive listener has a wait equal to the modeled busy time; a passive listener has a zero scroll-start wait. Real browsers add more detail, including event type and CSS.
Passive listeners and cancelable events
A cancelable event has a default action that a listener may be able to stop. Read event.cancelable before writing cancellation logic. Calling preventDefault() on a non-cancelable event does not change its default action.
const target = new EventTarget(); target.addEventListener("checkout", (event) => { console.log("cancelable", event.cancelable); event.preventDefault(); console.log("defaultPrevented", event.defaultPrevented);}, { passive: true }); target.dispatchEvent(new Event("checkout", { cancelable: true }));Line 3 adds a passive listener. Line 4 first prints cancelable true. Line 5 asks to prevent the default action, but that request is ignored because the listener promised not to cancel. Line 6 therefore prints defaultPrevented false in browsers.
Node's EventTarget does not model passive cancellation the way browser DOM does, so the lesson test proves this snippet in Chrome. Do not use a passive listener when cancellation is genuinely part of the feature. Prefer CSS such as touch-action when it describes the gesture policy more directly.
// State the option instead of depending on a browser default.document.addEventListener("wheel", recordScroll, { passive: true });document.addEventListener("touchstart", startPan, { passive: false }); console.log("wheel observes; touchstart may cancel");Line 2 explicitly says the wheel listener only observes. Line 3 explicitly says this touch listener may cancel because the feature needs that choice. Line 5 prints wheel observes; touchstart may cancel. Explicit options are clearer than relying on a target-specific default.
MDN documents that modern browsers other than Safari commonly default document-level wheel, touchstart, and touchmove listeners to passive. That is a compatibility behavior, not a reason to omit the option: use passive: false when cancellation is intentional and test the gesture on the browsers you support.
Hit testing finds the target
Hit testing asks which painted, eligible element is underneath a viewport coordinate. When an input must reach JavaScript, the main thread uses rendering information to find the event target before event dispatch follows capture, target, and bubble rules.
const back = document.createElement("button");back.textContent = "Pay";back.style.cssText = "position:fixed;left:20px;top:20px;width:120px;height:60px"; const cover = document.createElement("div");cover.style.cssText = "position:fixed;left:20px;top:20px;width:120px;height:60px;pointer-events:none"; document.body.append(back, cover);console.log(document.elementFromPoint(40, 40)?.textContent);Lines 1 through 4 build a button. Lines 5 through 7 build a cover directly over it, but the cover has pointer-events: none. Line 9 asks for the element at (40, 40) and prints Pay, because the cover is invisible to hit testing.
This is useful for decorative overlays, labels, and effects that should not block a real control beneath them. It does not remove the cover from the DOM or stop events that target another element from bubbling through ancestors in their normal way.
A component can also place its real control inside a shadow tree. Hit testing may find that inner control, while a listener outside the shadow tree can see event.target retargeted to the component host. That boundary keeps component internals private; use the event's composed path only when your component contract needs that extra detail.
Coalesced and predicted pointer events
Touch and pointer hardware can report movement more often than the display draws frames. Delivering every move to JavaScript would create too much hit testing and handler work. Browsers can merge several moves into one delivered pointermove near a frame.
window.addEventListener("pointermove", (event) => { const points = event.getCoalescedEvents?.() ?? [event]; const guesses = event.getPredictedEvents?.() ?? []; console.log("points", points.length); console.log("guesses", guesses.length);});Line 2 reads historical points with optional chaining, falling back to the delivered event. Line 3 asks for estimated future points only when that method exists. Lines 4 and 5 print the counts. The fallback makes the code safe in browsers that do not support one or both APIs.
getCoalescedEvents() returns earlier real points merged into this delivery and is valuable for a drawing path. Predicted events are estimates of future positions based on recent velocity and direction. They can reduce perceived drawing latency, but later real input can disagree.
const rawMoves = [ { x: 10, y: 10, time: 0 }, { x: 14, y: 10, time: 4 }, { x: 18, y: 10, time: 8 }, { x: 22, y: 10, time: 12 },]; const deliveries = coalesceRawMoves(rawMoves, 3);console.log(deliveries.length);console.log(deliveries[0].coalesced.length);console.log(deliveries[0].delivered.x);Lines 2 through 5 describe four raw moves. Line 7 groups them in threes. Line 8 prints 2 because two JavaScript deliveries are enough for four raw moves. Line 9 prints 3, and line 10 prints 18, the last point in the first delivered group.
In browser code, the delivered event is the final point and getCoalescedEvents() can provide the earlier positions. Draw every returned real point in order. Keep a predicted point separate, because the next actual delivery may require you to correct that temporary stroke.
Replay a small sample-count model. Browsers choose when and how many points to coalesce or predict.
script
function samplesFor(event) { return { points: event.coalesced, guesses: event.predicted, };} console.log(samplesFor(event));Support is not uniform: MDN marks coalesced events as limited availability, and predicted events as newly available baseline support. Feature detection is therefore more honest than assuming every pointer event has these methods or returns the same number of samples.
Try the scroll-start model
Use this playground to change one decision at a time. Passive means the compositor may start scrolling right away in this model. “May cancel” means it waits for the stated main-thread busy time, because JavaScript could call preventDefault().
const passive = true;const mainThreadBusyMs = 40; function startScroll(passive, mainThreadBusyMs) { return passive ? 0 : mainThreadBusyMs;} console.log(startScroll(passive, mainThreadBusyMs));fast region | compositor | wait 0ms | immediatelyChange only the listener promise or busy time, then compare when scrolling may start.
The compositor can begin default scrolling immediately. The busy time still affects other work, but this listener does not hold up scroll start.
This is a teaching model rather than a profiler. It deliberately has only two inputs so the causal rule remains visible. On a real page, inspect the event type, listener options, target, CSS gesture policy, and a performance trace before deciding what caused jank.
Practical listener choices
Mark a listener passive when it only observes an event. A wheel analytics callback, a visual readout, or a listener that schedules ordinary work is often a good candidate. Write the option explicitly so a future reader does not depend on browser-specific default-passive behavior.
Keep a non-passive listener only when cancellation is truly required. For touch gestures, first ask whether touch-action can describe the browser gesture policy. CSS lets the browser know earlier than a JavaScript handler that it may need to stop or allow a pan.
Fix a janky scroll handler
Start with the smallest useful change. A cart preview that only observes wheel movement should not save data inside every wheel callback, and it should not leave the browser guessing whether the callback might cancel scrolling.
window.addEventListener("wheel", (event) => { updateCartPreview(event.deltaY); saveCart(); console.log("preview updated");});Line 1 gives no passive promise. Line 2 updates a preview, line 3 starts a save, and line 4 prints preview updated. Saving work on every wheel delivery can make the main thread busy even when the handler never calls preventDefault().
window.addEventListener("wheel", (event) => { requestAnimationFrame(() => updateCartPreview(event.deltaY)); console.log("preview scheduled");}, { passive: true });Line 2 batches the preview update for a rendering frame. Line 3 prints preview scheduled, and line 4 promises this listener will not cancel. Debounce or save after the gesture separately; passive avoids scroll-start waiting, but it does not make expensive work free.
| Concept | Meaning | What it changes |
|---|---|---|
| Passive listener | Promises it will not cancel the default action. | The browser can start a scroll without waiting for that listener. |
| Non-passive listener | May call preventDefault() on a cancelable event. | A scrollable area may need main-thread confirmation first. |
| Coalesced point | A real earlier point merged into a delivered pointer event. | Useful when a drawing path needs more detail. |
| Predicted point | An estimated future position based on movement so far. | Useful only as a temporary guess that later real input can correct. |
- A wheel listener registered with
{ passive: true }. - A scrollable area with no potentially canceling listener.
- A non-passive touch listener that might call
preventDefault(). - A potentially canceling touch listener high on the page.
- Earlier pointer positions from
getCoalescedEvents(). - Estimated future positions from
getPredictedEvents().
Sort each card by whether scrolling can begin, main-thread confirmation is needed, or it is extra pointer data.
Common misconceptions
- “Passive means no handler runs.” The handler runs normally; it simply cannot cancel the default action.
- “All scrolling waits for JavaScript.” Browsers can often scroll on the compositor when they have no cancellation reason to wait.
- “A cover always receives the event.” Hit testing skips a cover with
pointer-events: none. - “Predicted points happened already.” They are estimates and must not replace actual input history.
| Myth | What is true | Practical response |
|---|---|---|
| Passive means the handler does not run. | The handler still runs; it only gives up cancellation. | Use passive for observation such as analytics or visual updates. |
| Every wheel event waits for JavaScript. | Potentially canceling listeners can require waiting; passive listeners let the browser proceed. | Browser defaults also vary by target and event, so state the option explicitly. |
| pointer-events: none disables all pointer events. | It removes that element from hit testing; another element may become the target. | It is often useful for a visual overlay that should not block a button. |
| Predicted points are facts. | They are browser estimates and can differ from later actual input. | Draw them separately and be ready to correct the result. |
Practice exercises
Type the program's output.
const passive = true;
console.log(passive ? "scroll now" : "wait");It prints scroll now. The Boolean is true, so the conditional expression chooses its first value.
Which property tells you whether cancellation can matter?
if (event.cancelable) event.preventDefault();Read event.cancelable before relying on cancellation. A passive listener still cannot cancel even when the event itself is cancelable.
Name the API used to find the element under a viewport point.
const target = document.elementFromPoint(x, y);The method performs a DOM hit test and returns the topmost eligible element at that point.
What CSS value should a purely visual cover use?
.cover { pointer-events: none; }The cover still renders, but the hit test can find a control beneath it.
Which method returns earlier pointer positions merged into one delivery?
const points = [10, 15, 20];
console.log(points.length);const points = event.getCoalescedEvents?.() ?? [event];Feature-detect getCoalescedEvents() so a drawing app can use earlier real points when available.
Your store records wheel activity but never cancels it. What option should its listener declare?
window.addEventListener("wheel", recordScroll, { passive: true });Analytics has no reason to block the default wheel action, so it should explicitly declare passive: true.
Check your understanding
For each question, separate browser routing, cancellation permission, target selection, and the fidelity of pointer data.
Question 1 of 6What does
{ passive: true }promise?Choose an answer to see the explanation.
Question 2 of 6What does this print?
Read the code, then predictconst passive = true; console.log(passive ? "scroll now" : "wait");Choose an answer to see the explanation.
Question 3 of 6Why can a non-passive wheel or touch listener delay scroll start?
Choose an answer to see the explanation.
Question 4 of 6What does
document.elementFromPoint(40, 40)return?Choose an answer to see the explanation.
Question 5 of 6When are coalesced pointer samples useful?
Choose an answer to see the explanation.
Question 6 of 6What is honest handling of predicted pointer events?
Choose an answer to see the explanation.
Key takeaways
- The compositor can keep a scroll smooth when it has no reason to wait for a potentially canceling listener.
- A non-fast scrollable region represents work that may need main-thread confirmation before scroll starts.
- Passive means “I will not cancel”;
preventDefault()in that listener is ignored. - Hit testing finds the element beneath a coordinate, and
pointer-events: noneremoves a visual cover from that search. - Coalesced points are earlier real movement; predicted points are optional estimates.
Remember the one-liner.
Input stays smooth when the browser knows it does not need to wait for your JavaScript.
Coming next: Bindings: how DOM objects exist in JavaScript.