Performant Alternative to addEventListener for Scroll and Resize Events

Using addEventListener to handle scroll and resize events is convenient, but it might be costing your application more than your realize in terms of performance.

Taken from a (simplified) real-world example on one of my site, here is our initial code using addEventListener.

The function myCoolFunc executes for both a scroll event (on a scrollable element with overflow in this case) and a window resize event.

const myElem = document.querySelector('.myElem'); function myCoolFunc(e) { // Code console.log("Executing myCoolFunc"); } myElem.addEventListener('scroll', myCoolFunc, {passive: true}); window.addEventListener('resize', myCoolFunc, {passive: true}); myCoolFunc(); // optional
Code language: JavaScript (javascript)

The initial function call at the end is optional depending on what you’re trying to do.

If I scroll normally to the end and back, myCoolFunc executes 20-50 times (depending on the viewport size and therefore how much scroll is available). If I resize the window just for a moment, it executes around 6 times.

A console log with the original code displaying a higher rate of execution of code

You may agree this is more than necessary for your application.

Throttling the Scroll Event

Let’s throttle this function for the scroll event. Basically we are going to limit the rate of execution and increase the time between calls.

We’ll use a boolean initialized as false.

let scrollThrot = false; const myElem = document.querySelector('.myElem'); function myCoolFunc(e) { // Code console.log("Executing myCoolFunc"); } myElem.addEventListener('scroll', function(){ // only run if we're not throttled if (!scrollThrot) { // actual callback action console.log("Calling throttled function."); myCoolFunc(); // we're throttled! scrollThrot = true; // set a timeout to un-throttle setTimeout(function() { scrollThrot = false; }, 400); } }, {passive: true}); window.addEventListener('resize', myCoolFunc, {passive: true}); myCoolFunc();
Code language: JavaScript (javascript)

Instead of calling myCoolFunc directly on the scroll event, we’ll use an anonymous function which only calls myCoolFunc according to the setTimeout conditions. In this case, every 400ms.

Now let’s try scrolling to the end and back again. This time, it only fires a few times in total.

A console log displaying rate limited throttled code for scrolling events

Of course, you can adjust the timeout value to your liking.

Debouncing & ResizeObserver

Now let’s reduce the frequency of execution of the resize event. This time, we’ll use the resizeObserver API along with a technique called debouncing.

First, we set up the resizeObserver simply like so:

const resizeObserver1 = new ResizeObserver((entries) => { myCoolFunc(); }); resizeObserver1.observe(myElem);
Code language: JavaScript (javascript)

We’ll go ahead and remove the resize event listener from our code. We also can remove the initial function call at the end, because it will be called as soon as we start observing anyway.

Our code all together at this point is:

let scrollThrot = false; const myElem = document.querySelector('.myElem'); const resizeObserver1 = new ResizeObserver((entries) => { myCoolFunc(); }); resizeObserver1.observe(myElem); function myCoolFunc(e) { // Code console.log("Executing myCoolFunc"); } myElem.addEventListener('scroll', function(){ // only run if we're not throttled if (!scrollThrot) { // actual callback action console.log("Calling throttled function."); myCoolFunc(); // we're throttled! scrollThrot = true; // set a timeout to un-throttle setTimeout(function() { scrollThrot = false; }, 400); } }, {passive: true});
Code language: JavaScript (javascript)

Now if we see how many times myCoolFunc executes when I resize the window like before, we notice that it is about the same, about 6 times.

Let’s reduce the frequency of execution by debouncing. This means we wait until the event has finished for a specified amount of time before executing.

We’ll initialize a boolean for the timeout. Each time the resize is observed, we will clear our timeout and set a new timeout, making the function not run at all until the specified time after the last observed resize.

let timeoutResize1 = false; // holder for timeout id const resizeObserver1 = new ResizeObserver((entries) => { clearTimeout(timeoutResize1); // clear the timeout // start timing for event "completion", where the second numerical param is the delay after event is "complete" to run callback timeoutResize1 = setTimeout(myCoolFunc, 250); }); resizeObserver1.observe(myElem);
Code language: JavaScript (javascript)

Now when I resize the window, myCoolFunc only executes once when I am finished resizing! So our full code now is:

let scrollThrot = false; let timeoutResize1 = false; // holder for timeout id const myElem = document.querySelector('.myElem'); const resizeObserver1 = new ResizeObserver((entries) => { // debouncing clearTimeout(timeoutResize1); // clear the timeout // start timing for event "completion", where the second numerical param is the delay after event is "complete" to run callback timeoutResize1 = setTimeout(myCoolFunc, 250); }); resizeObserver1.observe(myElem); function myCoolFunc(e) { // Code console.log("Executing myCoolFunc"); } myElem.addEventListener('scroll', function(){ // only run if we're not throttled if (!scrollThrot) { // actual callback action console.log("Calling throttled function."); myCoolFunc(); // we're throttled! scrollThrot = true; // set a timeout to un-throttle setTimeout(function() { scrollThrot = false; }, 400); } }, {passive: true});
Code language: JavaScript (javascript)

Further Improvements & Adjustments

The above steps have achieved our purpose of improving performance, but I still had some issues and side-effects.

For the scrolling action, the timeout/throttled approach was not executed frequently enough to be able to catch a condition I had in my function for when the element was fully scrolled to the end.

I solved this by combining throttling and debouncing! This way, we can guarantee myCoolFunc will execute the function at the last known/currently scrolled position.

All we have to add is an else condition for when we are throttled, at which point we want to call the function after a short amount of time.

let timeoutScroll1 = false; // holder for timeout id myElem.addEventListener('scroll', function(){ // only run if we're not throttled if (!scrollThrot) { // actual callback action console.log("Calling throttled function."); myCoolFunc(); // we're throttled! scrollThrot = true; // set a timeout to un-throttle setTimeout(function() { scrollThrot = false; }, 400); }else{ // debouncing, only when throttled clearTimeout(timeoutScroll1); // clear the timeout // start timing for event "completion", where the second numerical param is the delay after event is "complete" to run callback timeoutScroll1 = setTimeout(myCoolFunc, 50); } }, {passive: true});
Code language: JavaScript (javascript)

Another change I wanted to make is to not trigger the resizeObserver immediately on page load (which can potentially result in wasted layout recalculations), but only when the user first resizes the window.

I can simply use a one-time event listener as so:

// One-time event listener to kick off the resize observer so that the observer doesn't trigger on page load and cause layout reflow window.addEventListener('resize', () => { resizeObserver1.observe(myElem); // console.log("We are now observing for resize."); }, {once: true, passive: true});
Code language: JavaScript (javascript)

Conclusion

Your use case may not require all of the adjustments we’ve made, but this is the full code showing everything mentioned in this article:

let scrollThrot = false; let timeoutResize1 = false; // holder for timeout id let timeoutScroll1 = false; // holder for timeout id const myElem = document.querySelector('.myElem'); const resizeObserver1 = new ResizeObserver((entries) => { clearTimeout(timeoutResize1); // clear the timeout // start timing for event "completion", where the second numerical param is the delay after event is "complete" to run callback timeoutResize1 = setTimeout(myCoolFunc, 250); }); // One-time event listener to kick off the resize observer so that the observer doesn't trigger on page load and cause layout reflow window.addEventListener('resize', () => { resizeObserver1.observe(myElem); // console.log("We are now observing for resize."); }, {once: true, passive: true}); function myCoolFunc(e) { // Code console.log("Executing myCoolFunc"); } myElem.addEventListener('scroll', function(){ // only run if we're not throttled if (!scrollThrot) { // actual callback action console.log("Calling throttled function."); myCoolFunc(); // we're throttled! scrollThrot = true; // set a timeout to un-throttle setTimeout(function() { scrollThrot = false; }, 400); }else{ // debouncing, only when throttled clearTimeout(timeoutScroll1); // clear the timeout // start timing for event "completion", where the second numerical param is the delay after event is "complete" to run callback timeoutScroll1 = setTimeout(myCoolFunc, 50); } }, {passive: true});
Code language: JavaScript (javascript)

Leave a Comment