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 when a scroll waitsSay when the compositor can start a scroll on its own, and when it must wait for the main thread.
- 02Choose listener optionsMake a listener passive only when it never cancels, and check
event.cancelablebefore you count onpreventDefault(). - 03Handle pointer input wellFind the element under a point with hit testing, and check for extra real and guessed pointer points before you use them.
One input, three choices for the browser
You move a finger on a touch screen. You turn the mouse wheel. You click. Each time, the browser first learns two facts: where on the screen it happened, and what kind of input it was. Then the browser has to make three choices:
- Can it change the screen right away, for example by scrolling?
- Must it first give your JavaScript a chance to stop that change?
- Which element on the page should get the event?
The input pipeline is the work the browser does with each input. It finds the element under the input. It runs your event listeners when it needs to. Then it does the normal action for that input, such as scrolling. That normal action is called the default action.
This lesson builds on the rendering pipeline. In that lesson, the browser painted parts of the page into layers. A part of the browser called the compositor puts those layers together on the screen, quickly. This lesson asks: can the compositor keep a scroll moving on its own, or must it wait for the main thread? The main thread is where your JavaScript runs.
These words describe how a browser is built. They are not a second event loop inside JavaScript. Your event listener still runs on the main thread, like the rest of your JavaScript. The question that matters is simple: must the browser wait for your listener before it starts the default action?
Scrolling without waiting for JavaScript
A thread is one line of work that a program runs. The browser has several. The compositor thread puts already-painted layers together into frames. A frame is one picture on the screen. For a scroll, the compositor thread can often just move the layers. It doesn't need the main thread to run JavaScript or to measure the page again. That is one reason a page can keep scrolling while the main thread is busy.
window.addEventListener("wheel", () => console.log("wheel seen"), { passive: true,});console.log("listener added");Line 1 adds a listener for the wheel event on window. The wheel event fires when the user turns the mouse wheel or swipes on a trackpad. Line 2 marks the listener as passive. Passive is a promise: "this listener will not cancel the event." Line 4 runs straight away and prints listener added. Later, each turn of the wheel prints wheel seen.
A passive listener still runs. You can use it to watch the input, count it, or update a small display. Passive does not mean "skipped". It means the browser can start the scroll without waiting to see whether this listener calls preventDefault(). A listener calls preventDefault() to stop the default action.
A guard at a building gate can wave cars through at once. But at some gates, the office has said: "Call me before you let anyone in." There, every car waits while the guard calls and the office answers. A passive listener is like the office saying: "No need to call me."
- In real life: The guard waves cars through
- In JavaScript: The compositor starts a scroll
- In real life: The office may stop a car
- In JavaScript: Your listener may cancel the default action
- In real life: The office says: no need to call
- In JavaScript: A passive listener promises not to cancel
- In real life: Cars wait while the guard calls
- In JavaScript: The scroll waits for a listener that may cancel
Non-fast regions: where a scroll must wait
Some parts of a page have a listener that might cancel a scroll. A non-fast scrollable region is such a part. Before a scroll starts there, the compositor may need an answer from the main thread. Chrome keeps a note of these parts while it builds 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(","));markScrollableRegions is a small function written for this lesson. It is not part of the browser. It takes a list of listeners and marks each part of the page as fast or non-fast:
- Line 2:
carthas awheellistener that is not passive. So the model marks itnon-fast. - Line 3:
menuhas a passivetouchmovelistener.touchmovefires while a finger moves on a touch screen. The listener is passive, somenuisfast. - Line 4:
maphas apointermovelistener. It runs JavaScript, but this small model doesn't countpointermoveas a listener that can hold up a scroll. Somapisfasttoo.
Line 7 calls the model. Line 8 prints non-fast,fast,fast. This is a model for learning, not Chrome's real list of regions. It shows one rule clearly: the listeners that usually make a scroll wait are wheel, touchstart, and touchmove listeners that are not passive. touchstart fires when a finger first touches the screen.
non-fastwheel may cancel
fasttouchmove cannot hold up scroll in this model
fastpointermove cannot hold up scroll in this model
A wheel or touch listener that is not passive might call preventDefault(). That would stop the scroll. The browser can't know what the listener will do until it runs it. So the scroll waits. In the lesson's model, the main thread is busy for 40 ms (40 milliseconds, or 40 thousandths of a second) before it can run the listener.
passive: true, the compositor starts the scroll at once. With passive: false, the scroll waits until the main thread has run the listener, 40 ms in the lesson's model.Follow one wheel event through a small scroll-start model with fixed numbers.
script
const mainThreadBusyMs = 40; function startScroll(passive, mainThreadBusyMs) { return passive ? 0 : mainThreadBusyMs;} console.log(startScroll(passive, mainThreadBusyMs));The step-through uses a small model with fixed numbers. It does not measure Chrome. It shows the link plainly. A listener that is not passive makes the scroll wait as long as the main thread is busy. A passive listener makes it wait 0 ms. Real browsers look at more things too, such as the event type and the page's CSS.
Passive listeners and cancelable events
Some events have a default action that a listener can stop. These events are called cancelable. You can check with event.cancelable. It is true when the default action can be stopped. If an event is not cancelable, calling preventDefault() changes nothing.
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 1 makes an EventTarget. An EventTarget is a plain object that can have listeners and receive events, like an element on a page. Line 3 adds a listener for a made-up checkout event. Line 7 makes that listener passive. Line 9 sends a checkout event that is cancelable. Then the listener runs:
- Line 4 prints
cancelable true. - Line 5 asks to stop the default action. The browser ignores this, because the listener promised not to cancel.
- Line 6 prints
defaultPrevented false.defaultPreventedtells you whether the default action was stopped.
preventDefault() does nothing, and line 6 prints defaultPrevented false.Node's EventTarget doesn't handle passive listeners the way browsers do. So the lesson's test checks this example in Chrome. If your feature really needs to cancel, don't make the listener passive. For touch gestures, the CSS property touch-action is often a better choice. It tells the browser ahead of time which finger movements the page handles itself.
// Always write the passive option. Don't rely on the browser's default.document.addEventListener("wheel", recordScroll, { passive: true });document.addEventListener("touchstart", startPan, { passive: false }); console.log("wheel observes; touchstart may cancel");recordScroll and startPan stand for your own functions. Line 2 says the wheel listener only watches. Line 3 says the touch listener may cancel, because the feature needs that. Line 5 prints wheel observes; touchstart may cancel. Writing the option out is clearer than trusting a default that changes from place to place.
MDN, Mozilla's web documentation, says this about defaults. Modern browsers other than Safari often make wheel, touchstart, and touchmove listeners on the window or the document passive, unless you say otherwise. Browsers do this to keep old pages scrolling smoothly. It is not a reason to leave the option out. Write passive: false when you really mean to cancel. Then test the gesture in each browser you support.
Hit testing finds the target
Hit testing means finding which element is under a point on the screen. The browser looks for the top element at that point that is allowed to receive input. A point is given as two numbers in pixels: x, from the left edge of the visible page, and y, from the top. The visible part of the page is called the viewport.
When an input needs to reach JavaScript, the main thread does this search first. The element it finds becomes the event target. Then the event travels through the page as usual: down to the target, at the target, and back up through its parent elements.
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 to 3 make a Pay button and place it 20 pixels from the top left corner. Lines 5 and 6 make a cover: a div of the same size, in the same place, on top of the button. The cover has pointer-events: none. Line 8 adds both to the page.
Line 9 calls document.elementFromPoint(40, 40). That method returns the top element at that point that can receive input. The ?. gives undefined instead of an error if no element was found. Line 9 prints Pay, because the search skips the cover.
pointer-events: none, so the search for the element at (40, 40) skips it and finds the Pay button.This is handy for decorations, labels, and effects that sit on top of a real button. The cover still shows on the screen, and it is still in the page. It just never becomes the target of a click or a tap. Events on other elements still travel up through their parent elements as usual.
A component can keep its real button inside a shadow tree. A shadow tree is a hidden part of a component that the rest of the page can't reach into. Hit testing may find that inner button. But a listener outside the component sees event.target as the component itself, which is called the host. This keeps the component's inside private. If your component really needs the inner element, event.composedPath() lists every element the event passes through.
Extra pointer points: coalesced and predicted
A pointer is anything that points at the screen: a mouse, a pen, or a finger. Pointer hardware can report movement more often than the screen draws frames. If JavaScript got every single move, it would have too much work: a hit test and a listener call for each one. So the browser can merge several moves into one pointermove event, sent close to each frame. Merging like this is called coalescing.
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 asks for the merged real points with
getCoalescedEvents(). The?.()calls the method only if it exists. If it doesn't,?? [event]uses a list with just this one event. - Line 3 asks for guessed future points with
getPredictedEvents()in the same safe way. If the method doesn't exist, it uses an empty list. - Lines 4 and 5 print how many of each there are.
Checking that a feature exists before you use it is called feature detection. It keeps this code safe in browsers that lack one or both methods.
getCoalescedEvents() returns the real positions merged into this event, including the last one. A drawing app needs them to draw a smooth line. Predicted points are guesses at where the pointer will go next. The browser bases them on the pointer's recent speed and direction. They can make drawing feel faster, but the next real move may turn out different.
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 1 to 6 list four raw moves. "Raw" means straight from the hardware. Each move has an x, a y, and a time. Line 8 calls coalesceRawMoves, a small function written for this lesson. It groups the moves in threes. Then:
- Line 9 prints
2, because four moves need two deliveries. - Line 10 prints
3: the first delivery holds 3 moves. - Line 11 prints
18: the x of the last move in the first delivery.
In this model, the event that JavaScript gets is the last move in each group. In real browser code, draw the returned real points in order. Don't draw the event's own point a second time if it is already in the list. Keep a predicted point apart from the real line, because the next real move may show that you need to fix it.
Follow a small model that counts points. Real browsers choose when to merge points, and how many to guess.
script
function samplesFor(event) { return { points: event.coalesced, guesses: event.predicted, };} console.log(samplesFor(event));Not every browser supports these methods. MDN marks getCoalescedEvents() as having limited support. It marks getPredictedEvents() as newly working in all the main browsers. So check that each method exists. Don't assume every pointer event has them, or that they always return the same number of points.
Try the scroll-start model
Use this playground to change one thing at a time. With Passive listener, the compositor starts the scroll at once in this model. With May cancel, the scroll waits for the main-thread busy time you type. It waits 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 one thing, the listener or the busy time. Then see when the scroll can start.
The compositor can start the scroll right away. The busy main thread still slows other work, but this listener doesn't make the scroll wait.
This is a model for learning, not a measuring tool. It has only two inputs on purpose, so cause and effect stay easy to see. On a real page, scrolling that stutters is called jank. Before you decide what caused it, check these things:
- the event type,
- the listener options,
- the target element,
- the CSS
touch-actionrule, - and a performance recording from the browser's developer tools.
Choosing listener options in real code
Make a listener passive when it only watches an event. Good examples are a wheel listener that counts scrolling for analytics, a small on-screen readout, or a listener that only schedules work for later. Write the option out, so a future reader doesn't need to know each browser's default.
Keep a listener non-passive only when it really must cancel. For touch gestures, first ask whether the CSS touch-action property can describe what you want. A pan is a finger drag that moves the page. CSS tells the browser earlier than a JavaScript listener whether to allow a pan or stop it.
Interaction to Next Paint (INP) is a score for how fast a page responds. It measures the time from a click, a tap, or a key press until the next frame shows the result. That time includes the wait before your listener starts and the time your listener takes. Scrolling and zooming don't count toward INP. So a passive wheel listener can help scrolling start sooner, but on its own it doesn't prove a better INP.
Fix a wheel listener that makes scrolling stutter
Start with the smallest useful change. Picture a shop page that shows a cart preview while you scroll. The preview only watches the wheel. So it shouldn't save data on every wheel event. It also shouldn't leave the browser guessing whether it might cancel the scroll.
window.addEventListener("wheel", (event) => { updateCartPreview(event.deltaY); saveCart(); console.log("preview updated");});updateCartPreview and saveCart stand for your own functions. Line 1 gives no passive promise. Line 2 updates the preview. Line 3 starts a save. Line 4 prints preview updated. Saving on every wheel event can keep the main thread busy, even though the listener never calls preventDefault().
window.addEventListener("wheel", (event) => { requestAnimationFrame(() => updateCartPreview(event.deltaY)); console.log("preview scheduled");}, { passive: true });- Line 2 calls
requestAnimationFrame. It asks the browser to run the preview update just before the next frame is drawn. - Line 3 prints
preview scheduled. - Line 4 promises that this listener won't cancel.
Each wheel event still schedules its own update. If you want several events to share one update, keep a flag that says an update is already waiting. Save the cart at another time, for example after the scrolling stops. Passive stops the scroll from waiting, but it doesn't make slow work fast.
The table below puts four similar ideas side by side. Read each row as: this idea means this, and it changes this.
| Idea | What it means | What it changes |
|---|---|---|
| Passive listener | It promises it will not cancel the default action. | The browser can start a scroll without waiting for this listener. |
| Non-passive listener | It may call preventDefault() on a cancelable event. | The scroll may have to wait for an answer from the main thread. |
| Coalesced point | A real earlier move that was merged into the event. The list includes the event's own last point. | A drawing app can use these points to draw a smoother line. |
| Predicted point | A guess about where the pointer goes next, based on how it just moved. | Use it only for a moment. The next real move may show it was wrong. |
- A wheel listener added with
{ passive: true }. - A scrolling area with no listener that might cancel.
- A non-passive touch listener that might call
preventDefault(). - A touch listener high up in the page, such as on the body, that might cancel.
- Real pointer positions from
getCoalescedEvents(). - Guessed future positions from
getPredictedEvents().
Put each card in a group: the scroll can start now, the browser must check with the main thread, or the card is extra pointer data.
Common misconceptions
- "Passive means the listener doesn't run." The listener runs as usual. It just can't cancel the default action.
- "Every scroll waits for JavaScript." The compositor can often scroll on its own when no listener might cancel.
- "A cover always gets the click." Hit testing skips a cover that has
pointer-events: none. - "Predicted points already happened." They are guesses. Never use them in place of the real moves.
The table below lists each myth, what is really true, and what to do about it.
| Myth | What is true | What to do |
|---|---|---|
| Passive means the listener doesn't run. | The listener still runs. It only gives up the right to cancel. | Make listeners passive when they only watch, such as for analytics or a small display. |
| Every wheel event waits for JavaScript. | Only a listener that might cancel can make the scroll wait. With passive listeners, the browser goes ahead. | Browser defaults differ by event and by where the listener sits, so write the option every time. |
pointer-events: none turns off all pointer events. | It takes that element out of hit testing. Another element under it may become the target. | Use it on a cover that is only for looks, so it doesn't block a button. |
| Predicted points already happened. | They are the browser's guesses. The real moves that come later may be different. | Draw them apart from the real line, and be ready to fix them. |
Practice exercises
Type what the program prints.
const passive = true;
console.log(passive ? "scroll now" : "wait");It prints scroll now. The ? : operator picks the first value when the condition is true, and passive is true.
Which property of the event tells you whether its default action can be stopped?
if (event.cancelable) event.preventDefault();Check event.cancelable before you count on canceling. Remember that a passive listener still can't cancel, even when the event is cancelable.
Name the method that finds the element under a point in the viewport.
const target = document.elementFromPoint(x, y);The method does a hit test. It returns the top element at that point that can receive input.
A cover sits on top of a button only for looks. What CSS should the cover use so clicks reach the button?
.cover { pointer-events: none; }The cover still shows on the screen, but the hit test finds the button under it.
Which method returns the earlier pointer positions that were merged into one event?
const points = [10, 15, 20];
console.log(points.length);const points = event.getCoalescedEvents?.() ?? [event];Check that getCoalescedEvents() exists before you call it. Then a drawing app can use the earlier real points when the browser offers them.
Your shop records wheel activity but never cancels it. Which option should its listener use?
window.addEventListener("wheel", recordScroll, { passive: true });Analytics has no reason to stop the scroll. So write passive: true in the listener options.
Check your understanding
For each question, first decide what it is about. Is it about whether the scroll can start, whether a listener may cancel, which element was hit, or whether a pointer point is real or guessed?
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 make a scroll wait?
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 points useful?
Choose an answer to see the explanation.
Question 6 of 6What is the safe way to use predicted pointer points?
Choose an answer to see the explanation.
Key takeaways
- The compositor can keep a scroll smooth when no listener might cancel it.
- A non-fast scrollable region is a part of the page where a scroll may have to wait for the main thread before it starts.
- Passive means "I will not cancel". In a passive listener,
preventDefault()is ignored. - Hit testing finds the element under a point.
pointer-events: nonetakes a cover out of that search. - Coalesced points are earlier real moves. Predicted points are guesses, and not every browser gives them.
Remember the one-liner.
Input stays smooth when the browser knows it doesn't have to wait for your JavaScript.
Coming next: Bindings: how DOM objects exist in JavaScript.