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 userAF
for any layout writes (DOM mutations). - for instant effects still use
rAF
for any layout writes (DOM mutations).