3 min read

Use Passive Event Listeners to prevent scroll interruptions

Use Passive Event Listeners to prevent scroll interruptions

For years we've complained and asked for a way to bind to touch and mouse events that do not require and should not change the built in scrolling actions.

Available on Chrome 51, Chrome Mobile 51, Safari Mobile 51, and Android WebView release 51

Finally it has landed on our devices and as a dev i'm pretty excited, but there are a few gotcha's as usual.

Simple usage

The third existing option on addEventListener has been replaced, it is no longer a boolean for the capture argument but rather a new object called EventListenerOptions which has some new properties; capture as expected and the new passive which both take boolean values.

In a number of common scenarios events don't need to block scrolling - for instance:

  • User activity monitoring which just wants to know when the user was last active
  • touchstart handlers that hide some active UI (like tooltips)
  • touchstart and touchend handlers that style UI elements (without suppressing the click event).

For these scenarios, the passive option can be added (with appropriate feature detection) without any other code changes, resulting in a significantly smoother scrolling experience.

Disable scrolling, touch, or wheel events

There are a few complicated scenarios where the handler only wants to suppress scrolling under certain conditions, such as:

  • A UI element (like YouTube's volume slider) which slides on horizontal wheel events without changing the scrolling behavior on vertical wheel events
  • Swiping horizontally to rotate a carousel, dismiss an item or reveal a drawer, while still permitting vertical scrolling

Since there is no equivalent of "touch action" for wheel events, you can implement them with non-passive wheel listeners.

Some options include;

In this case, use touch-action: pan-y to declaratively disable scrolling that starts along the horizontal axis without having to call preventDefault(), and using touch-action: pan-x will work the same on the vertical axis.

To consistently disable scrolling by cancelling all touch or wheel events like;

  • Panning and zooming a map
  • Full-page/full-screen games

Use the touch-action: none on your EventListenerOptions.

Gotcha 1

Scroll modifications are disabled in handlers

By using this feature you effectively tell the browser that you will not be calling e.preventDefault() in your event handler which normally allows scrolling to start (at a later stage) while still executing your handler.

Therefore e.preventDefault() becomes redundant;

addEventListener(document, "touchstart", function(e) {
  console.log(e.defaultPrevented);  // will be false
  e.preventDefault();   // does nothing since the listener is passive
  console.log(e.defaultPrevented);  // still false
}, {passive: true});

Calling it while using passive: true has no impact and can be safely omitted.

Gotcha 2

Backwards compatibility is broken

As you might suspect from the new object EventListenerOptions replacing the third argument we have not only a cross browser conflict but also a supporting browser backwards compatibility issue.

If your app doesn't currently use the third option and you know for certain that it won't in future, you still cannot use passive event listeners now because by passing up the EventListenerOptions you are breaking your event listener on non-supporting browsers.

You will need to manually handle feature detection or use a polyfil.

Gotcha 3

Feature detection is a thread blocking check

The fact that the way we need to check for the feature is thread blocking makes this troublesome.

Try using the feature to ensure it is available;

var supportsPassive = false;
try {
  var opts = Object.defineProperty({}, 'passive', {
    get: function() {
      supportsPassive = true;
    }
  });
  window.addEventListener("test", null, opts);
} catch (e) {}

elem.addEventListener('touchstart', fn, supportsPassive ? { passive: true } : false); 

You might need to consider doing this only once, immediately after you've run all of your DOM ready functionality so as to not block initial rendering, and then defer all of your event listeners until last. This to me is an anti-pattern and unfortunately there is no better performing way to manage this limitation, you could make the decision to block initially rendering and do the check immediately before ready state 4, but that's your choice.

Alternatively you could use the defacto JavaScript feature detection library Modernizr but it's implementation is hidden and you'll not be sure if it is blocking or otherwise;

elem.addEventListener('touchstart', fn, Modernizr.passiveeventlisteners ? {passive:true} : false);

How about making passive default?

Not only have we been given a brilliant new feature that breaks backwards compatibility, we are also forced to opt-in to use it as it is not set by default. Considering the minority of use cases where you would not want to use passive one would expect the default be set to passive!

Some quick dev has put together a tiny Github repo to enable the setting by default when you call addEventListener, you can find it here.

In short

When talking about performance, scrolling is a key aspect of your app, this means that as soon as the users perceives a janky scroll on your page they will quickly bounce and not likely spread the word about what you've built unless in frustration.

I strongly suggest all people in a position to implement this do so ASAP.

Read all about how it works on Google Developers.

You can find the source code on Github