Improving Scroll Performance with Passive Event Listeners

Hero image for Improving Scroll Performance with Passive Event Listeners. Image by Universtock.
Hero image for 'Improving Scroll Performance with Passive Event Listeners.' Image by Universtock.

In Brief

Use passive: true for a touch or wheel listener when its handler will not call preventDefault(). This declaration lets the browser begin scrolling without waiting to see whether JavaScript cancels the default action. Keep a listener nonpassive when cancellation is genuinely required, and treat handler cost and throttling as separate performance decisions.

You put a finger on the page and move it, but the content seems to follow a fraction late. It no longer feels anchored to the gesture. This is scroll jank, and a touch event listener can cause it even when the listener rarely does anything interesting.

The browser has a decision to make. A touchmove or wheel handler is allowed to call preventDefault() and stop the normal scroll. Until the handler has run, the browser cannot know whether scrolling is still permitted.

That wait is necessary when cancellation is part of the interaction. It is wasted time when the code only observes input.


The Default Action Can Be Cancelled

Events can have a default action. A link click normally follows the link. A touch movement or wheel input may scroll the page. When a relevant event is cancelable, a listener can prevent that action:

surface.addEventListener('touchmove', (event) => {  event.preventDefault();  moveCustomMap(event.touches[0]);});

The browser cannot look at that function and reliably decide in advance whether the call will happen. It may be hidden behind a condition, delegated to another function, or supplied by code loaded later. Without an explicit contract, the safe choice is to wait for the listener.

That is a scheduling problem, not a statement that the handler performs a large amount of work. An empty cancelable listener can still make the browser preserve an opportunity for cancellation. Equally, a listener can be nonblocking for scrolling and still run expensive JavaScript later.

The ordinary scroll event is different. It reports that scrolling has taken place and is not cancelable, so marking a scroll listener passive does not unlock the same decision. Chrome's original passivelistener guidance makes that distinction explicit alongside the Chrome 51 introduction.


The Passive Promise

The eventlistener options object lets the code declare that a handler will not cancel the default action:

const recordTouchPosition = (event) => {  lastTouchY = event.touches[0].clientY;};document.addEventListener('touchmove', recordTouchPosition, {  passive: true,});

passive: true tells the browser that recordTouchPosition will not call preventDefault(). The browser no longer needs to hold the scroll decision open for this listener and can proceed without waiting for the callback to finish.

The word promise is useful here. Passivity is not a hint that the browser may ignore when convenient. It is a statement about what the listener is allowed to do. If a later edit adds cancellation, the registration and the handler no longer agree.

This optionsobject syntax wasn't universal in September 2017. Chrome shipped passive listeners in version 51, and other engines followed on their own schedules. Older browsers could treat an object in the third argument as the old Boolean capture value. Use feature detection when those engines are part of the support requirement:

let supportsPassive = false;try {  const options = Object.defineProperty({}, 'passive', {    get() {      supportsPassive = true;    },  });  window.addEventListener('testPassive', null, options);  window.removeEventListener('testPassive', null, options);} catch (error) {  // Use the Boolean capture argument below.}document.addEventListener(  'touchmove',  recordTouchPosition,  supportsPassive ? { passive: true } : false);

The getter is accessed only when the browser recognises the options dictionary member. The fallback passes false, preserving bubblephase registration without assuming support that isn't there. The period EventListenerOptions explainer documents both this detection problem and the cancellation contract.


When preventDefault() Is the Point

Some interactions deliberately replace the browser's normal response. A map surface may use onefinger movement to pan its own content whilst the surrounding page remains scrollable through other areas and keyboard controls:

mapSurface.addEventListener('touchmove', (event) => {  event.preventDefault();  panMap(event.touches[0]);}, false);

This listener must remain nonpassive because cancellation is part of its job. The decision should be reviewed carefully: the map also needs operable zoom and pan buttons, visible focus, and a way for touch users to leave the surface without trapping page movement. Passivity cannot settle those interaction requirements for you.

If the listener is incorrectly declared passive, preventDefault() cannot cancel the event:

mapSurface.addEventListener('touchmove', (event) => {  event.preventDefault();  console.log(event.defaultPrevented);  // false}, { passive: true });

A supporting browser may report the attempt in its console. More importantly, the default action continues. The fix is not to suppress the warning; it is to decide whether cancellation belongs to the interaction and register the listener accordingly.


Passive is Not the Same as Cheap

Imagine an observationonly listener that calculates positions for 500 elements on every touch movement. Marking it passive removes the browser's need to wait before starting the scroll, but the JavaScript still competes for mainthread time. The page can remain sluggish.

Three questions need separate answers:

QuestionPossible responseWhat it changes
May the handler cancel scrolling?Passive or nonpassive registrationWhether the browser must wait on cancellation intent
Is the handler called too often?Throttle or reduce observationsInvocation frequency
Is each call expensive?Reduce work and avoid forced layoutCost per invocation

Throttling does not prove a listener is safe to mark passive. A throttled custom gesture may still need to cancel the default action. Likewise, passivity does not remove layout thrashing inside the callback. It only addresses the event contract.

Keep the handler small, and be wary of geometry reads after DOM writes. If the feature only needs to know that scrolling occurred rather than track every movement, consider whether it can do less work or run after the interaction instead.


Measure the Scroll Interaction

Make a Chrome DevTools performance recording on the device class that shows the problem. Record the same controlled gesture with the listener in its required nonpassive form, then with a justified passive declaration. Look for the period's scrollblocking warning and compare when scrolling begins relative to listener execution.

Do not use the passive version if it breaks the interaction. Check vertical and horizontal movement, nested scrolling areas, touch alternatives, wheel input where applicable, keyboard operation, and console output. Confirm that event.defaultPrevented changes only in the nonpassive case that genuinely calls preventDefault().

Test outside Chrome as well. Chrome 51 establishes the historical starting point, not universal support. Record the exact browser versions used and keep the optionsobject fallback aligned with the project's real compatibility policy.

Finally, measure handler work independently. A trace may show that scroll starts promptly but JavaScript still occupies long sections of the main thread. That is not evidence that passive listeners failed. It is evidence that another performance problem remains.


Make the Event Contract Explicit

A passive listener answers one precise question: can this handler cancel the default action? If the answer is no, declare it and let the browser scroll without waiting. If the answer is yes, keep the listener nonpassive and justify the interaction carefully.

Then ask the other questions. How often does the handler run? What does each call cost? Does it force layout? Clear event intent removes one avoidable source of scroll delay, but smooth input still depends on keeping the JavaScript itself under control.


Postscript

Aug 2026: This article forms part of an archive restored from a previous version of my website. Its original publication date is accurate. During the restoration, I reviewed and updated it where appropriate for formatting, imagery, broken links, code correctness, and current internal references, whilst preserving the original technical context and intent.

Browser defaults for some touch and wheel event listeners have changed since this article was originally published, so current browser behaviour should always be verified rather than assumed. The underlying principle remains unchanged: passive event listeners help the browser make better scheduling decisions, but they do not reduce the cost of expensive JavaScript running inside the event handler itself.


Untangling a delivery problem?

Send the symptoms, constraints, and affected routes. I'll help identify whether the issue sits in the application, platform, content model, deployment path, or search surface.