scroll events: requestAnimationFrame VS requestIdleCallback VS passive event listeners

As we know, it's often advised to debounce scroll listeners so that UX is better when the user is scrolling.

However, I've often found libraries and articles where influential people like Paul Lewis recommend using requestAnimationFrame. However as the web platform progress rapidly, it might be possible that some advice get deprecated over time.

The problem I see is there are very different use-cases for handling scroll events, like building a parallax website, or handling infinite scrolling and pagination.

I see 3 major tools that can make a difference in term of UX:

  • requestAnimationFrame
  • requestIdleCallback
  • Passive event listeners

So, I'd like to know, per usecase (I only have 2 but you can come up with other ones), what kind of tool should I use right now to have a very good scroll experience?

To be more precise, my main question would be more related to infinite scrolling views and pagination (which generally do not have to trigger visual animations, but we want a good scrolling experience), is it better to replace requestAnimationFrame with a combo of requestIdleCallback + passive scroll event handler ? I'm also wondering when it makes sense to use requestIdleCallback for calling an API or handling the API response to let the scroll perform better, or is it something that the browser may already handle for us?


Solution 1:

Although this question is a little bit older, I want to answer it because I often see scripts, where a lot of these techniques are misused.

In general all your asked tools (rAF, rIC and passive listeners) are great tools and won't vanish soon. But you have to know why to use them.

Before I start: In case you generate scroll synced/scroll linked effects like parallax effects/sticky elements, throttling using rIC, setTimeout doesn't make sense because you want to react immediately.

requestAnimationFrame

rAF gives you the point inside the frame life cycle right before the browser wants to calculate the new style and layout of the document. This is why it is perfect to use for animations. First it won't be called more often or less often than the browser calculates layout (right frequency). Second it is called right before the browser does calculate the layout (right timing). In fact using rAF for any layout changes (DOM or CSSOM changes) makes a lot of sense. rAF is synced with the V-SYNC as any other layout rendering related stuff in the browser.

using rAF for throttle/debounce

The default example of Paul Lewis looks like this:

var scheduledAnimationFrame;
function readAndUpdatePage(){
  console.log('read and update');
  scheduledAnimationFrame = false;
}

function onScroll (evt) {

  // Store the scroll value for laterz.
  lastScrollY = window.scrollY;

  // Prevent multiple rAF callbacks.
  if (scheduledAnimationFrame){
    return;
  }

  scheduledAnimationFrame = true;
  requestAnimationFrame(readAndUpdatePage);
}

window.addEventListener('scroll', onScroll);

This pattern is very often used/copied, although it makes little till no sense in practice. (And I'm asking myself why no developer sees this obvious problem.) In general, theoretically it makes a lot of sense to throttle everything to at least the rAF, because it doesn't make sense to request layout changes from the browser more often than the browser renders the layout.

However the scroll event is triggered every time the browser renders a scroll position change. This means a scroll event is synchronized with the rendering of the page. Literally the same thing that rAF is giving you. This means it doesn't make any sense to throttle something by something, that is already throttled by the exact same thing per definition.

In practice you can check what I just said by adding a console.log and check how often this pattern "prevents multiple rAF callbacks" (answer is none, otherwise it would be a browser bug).

  // Prevent multiple rAF callbacks.
  if (scheduledAnimationFrame){
    console.log('prevented rAF callback');
    return;
  }

As you will see this code is never executed, it is simply dead code.

But there is a very similar pattern that make sense for a different reason. It looks like this:

//declare box, element, pos
function writeLayout(){
    element.classList.add('is-foo');
}

window.addEventListener('scroll', ()=> {
    box = element.getBoundingClientRect();

    if(box.top > pos){
        requestAnimationFrame(writeLayout);
    }
});

With this pattern you can successfully reduce or even remove layout thrashing. The idea is simple: inside of your scroll listener you read layout and decide wether you need to modify the DOM and then you call the function that modifies the DOM using rAF. Why is this helpful? The rAF makes sure that you move your layout invalidation (at the ende of the frame). This means any other code that is called inside the same frame works on a valid layout and can operate with super fast layout read methods.

This pattern is in fact so great, that I would suggest the following helper method (written in ES5):

/**
 * From https://stackoverflow.com/a/44779316
 *
 * @param {Function} fn Callback function
 * @param {Boolean|undefined} [throttle] Optionally throttle callback
 * @return {Function} Bound function
 *
 * @example
 * //generate rAFed function
 * jQuery.fn.addClassRaf = bindRaf(jQuery.fn.addClass);
 *
 * //use rAFed function
 * $('div').addClassRaf('is-stuck');
 */
function bindRaf(fn, throttle) {
  var isRunning;
  var that;
  var args;

  var run = function() {
    isRunning = false;
    fn.apply(that, args);
  };

  return function() {
    that = this;
    args = arguments;

    if (isRunning && throttle) {
      return;
    }

    isRunning = true;
    requestAnimationFrame(run);
  };
}

requestIdleCallback

Is from the API similar to rAF but gives something totally different. It gives you some idle periods inside of a frame. (Normally the point after the browser has calculated layout and done paint, but there is still some time left until the v-sync happens.) Even if the page is laggy from the users view, there might be some frames, where the browser is idling. Although rIC can give you max. 50ms. Most of the time you only have between 0.5 and 10ms to fulfill your task. Due to the fact at which point in the frame life cycle rIC callbacks are called you should not alter the DOM (use rAF for this).

At the end it makes a lot of sense to throttle the scroll listener for lazyloading, infinite scrolling and such using rIC. For these kinds of user interfaces you can even throttle more and add a setTimeout in front of it. (so you do 100ms wait and then a rIC)

Live examples for debounce and throttle.)

Here is also an article about rAF, that includes two diagrams which might help to understand the different points inside of a "frame lifecycle".

Passive event listener

Passive event listeners were invented to improve scroll performance. Modern browsers moved page scrolling (scroll rendering) from the main thread to the composition thread. (see https://hacks.mozilla.org/2016/02/smoother-scrolling-in-firefox-46-with-apz/)

But there are events that produce scrolling, which can be prevented by script (which happens in the main thread and therefore can revert the performance improvement).

Which means as soon as one of these events listeners are bound, the browser has to wait for these listener to be executed before the browser can compute the scroll. These events are mainly touchstart, touchmove, touchend, wheel and in theory to some degree keypress and keydown. The scroll event itself is not one of these events. The scroll event has no default action, that can be prevented by script.

This means if you don't use preventDefault in your touchstart, touchmove, touchend and/or wheel, always use passive event listeners and you should be fine.

In case you use preventDefault, check wether you can substitute it with the CSS touch-action property or lower it at least in your DOM tree (for example no event delegation for these events). In case of wheel listeners you might be able to bind/unbind them on mouseenter/mouseleave.

In case of any other event: It does not make sense to use passive event listeners to improve performance. Most important to note: The scroll event can't be canceled and therefore it never makes sense to use passive event listeners for scroll.

In case of an infinite scrolling view you don't need touchmove, you only need scroll, so passive event listeners do not even apply.

Resume

To answer your question

  • for lazyloading, infinite view use a combination of setTimeout + requestIdleCallback for your event listeners and use rAF for any layout writes (DOM mutations).
  • for instant effects still use rAF for any layout writes (DOM mutations).