completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

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.

By the end, you can
  • 01
    Explain when a scroll waitsSay when the compositor can start a scroll on its own, and when it must wait for the main thread.
  • 02
    Choose listener optionsMake a listener passive only when it never cancels, and check event.cancelable before you count on preventDefault().
  • 03
    Handle 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?
Definition

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.

Listen to the mouse wheel without stopping the scrollPop out in the code editor (opens in a new tab)JavaScript
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.

Real-life analogyA guard at a building gate

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.

Mark which parts of a page must wait (a small model)JavaScript
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: cart has a wheel listener that is not passive. So the model marks it non-fast.
  • Line 3: menu has a passive touchmove listener. touchmove fires while a finger moves on a touch screen. The listener is passive, so menu is fast.
  • Line 4: map has a pointermove listener. It runs JavaScript, but this small model doesn't count pointermove as a listener that can hold up a scroll. So map is fast too.

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.

cartnon-fast

wheel may cancel

menufast

touchmove cannot hold up scroll in this model

mapfast

pointermove 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.

Two rows. passive: true: wheel goes to the compositor, which starts the scroll; wait 0 ms. passive: false: wheel goes to the listener, which the main thread runs first; wait 40 ms.passive: truewheelcompositorstarts the scrollscrollwait 0 mspassive: falsewheellistenermain thread runs itscrollwait 40 ms
With 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.
Step through one wheel event with a listener that may cancel
Step 0 of 5Ready
Your turn: follow the blue line

Follow one wheel event through a small scroll-start model with fixed numbers.

Running in
  1. script
Next: line 1
const mainThreadBusyMs = 40; function startScroll(passive, mainThreadBusyMs) {  return passive ? 0 : mainThreadBusyMs;} console.log(startScroll(passive, mainThreadBusyMs));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Try to cancel inside a passive listenerPop out in the code editor (opens in a new tab)JavaScript
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. defaultPrevented tells you whether the default action was stopped.
Three cards joined by arrows: checkout, the event can be canceled; preventDefault(), called in a passive listener, so it is ignored; false, defaultPrevented stays false.checkoutthe eventcan be canceledpreventDefault()passive listener,so it is ignoredfalsedefaultPreventedstays false
The event can be canceled, but the listener promised not to cancel it. So 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.

Write the passive option in every listenerJavaScript
// 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.

Find the element under a pointPop out in the code editor (opens in a new tab)JavaScript
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.

A cover card sits on top of a Pay card. The cover has pointer-events: none, so the search skips it. The search finds Pay, and the console prints Pay.elementFromPoint(40, 40)coverpointer-events: noneso the search skips itPayfoundConsolePay
The cover sits on top of the button. It has 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.

Check for extra points before using themPop out in the code editor (opens in a new tab)JavaScript
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.

Four moves, delivered in groups of three (a small model)JavaScript
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.
Two carts. Delivery 1 holds the moves with x 10, 14, and 18; its last move has x 18. Delivery 2 holds the move with x 22.coalesceRawMoves(rawMoves, 3)1014181stdelivery 1: 3 moves, the last at x 18222nddelivery 2: 1 move
The four moves are grouped in threes, so they arrive in 2 deliveries. The first delivery holds 3 moves, and its last move, at x 18, is the one the event reports.

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.

Step through the real and guessed points of one event
Step 0 of 4Ready
Your turn: follow the blue line

Follow a small model that counts points. Real browsers choose when to merge points, and how many to guess.

Running in
  1. script
Next: line 1
 function samplesFor(event) {  return {    points: event.coalesced,    guesses: event.predicted,  };} console.log(samplesFor(event));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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().

Playground: does this wheel event have to wait?
The scroll-start model: wait 0 ms or the busy timePop out in the code editor (opens in a new tab)JavaScript
const passive = true;const mainThreadBusyMs = 40; function startScroll(passive, mainThreadBusyMs) {  return passive ? 0 : mainThreadBusyMs;} console.log(startScroll(passive, mainThreadBusyMs));
What the model works out
resultfast region | compositor | wait 0ms | immediately

Change only one thing, the listener or the busy time. Then see when the scroll can start.

Try it yourself

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.

A small model for learning, with fixed numbers. Real browsers also look at the event type, the target element, CSS such as touch-action, and their own inner workings.

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-action rule,
  • 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.

Before: a wheel listener that may hold up scrollingJavaScript
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().

After: a passive listener that schedules the previewJavaScript
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.

Four input ideas that are easy to mix up
IdeaWhat it meansWhat it changes
Passive listenerIt promises it will not cancel the default action.The browser can start a scroll without waiting for this listener.
Non-passive listenerIt may call preventDefault() on a cancelable event.The scroll may have to wait for an answer from the main thread.
Coalesced pointA 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 pointA 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.
Sort the input cards
  • 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().
Try it yourself
0 of 6 correct

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.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.

Common input myths and what is true
MythWhat is trueWhat 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

Exercise 1 · Warm-upPredict the passive output

Type what the program prints.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const passive = true;
console.log(passive ? "scroll now" : "wait");

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFind the property that says an event can be canceled

    Which property of the event tells you whether its default action can be stopped?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind the element under a point

      Name the method that finds the element under a point in the viewport.

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeStop a cover from blocking clicks

        A cover sits on top of a button only for looks. What CSS should the cover use so clicks reach the button?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeGet every real point for a drawing app

          Which method returns the earlier pointer positions that were merged into one event?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          const points = [10, 15, 20];
          console.log(points.length);

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeChoose the option for a real analytics listener

            Your shop records wheel activity but never cancels it. Which option should its listener use?

            Answer, then press Check. Spacing and letter case don’t matter.

              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?

              Input pipeline quiz · 6 questionsScore: first tries count
              1. Question 1 of 6What does { passive: true } promise?

                Choose an answer to see the explanation.

              2. Question 2 of 6What does this print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const passive = true;
                console.log(passive ? "scroll now" : "wait");

                Choose an answer to see the explanation.

              3. Question 3 of 6Why can a non-passive wheel or touch listener make a scroll wait?

                Choose an answer to see the explanation.

              4. Question 4 of 6What does document.elementFromPoint(40, 40) return?

                Choose an answer to see the explanation.

              5. Question 5 of 6When are coalesced pointer points useful?

                Choose an answer to see the explanation.

              6. 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: none takes 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.

              CompleteFrontend Clear concepts. Working examples.