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(); // optionalCode 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)

Intersection Observer

A wonderful addition to your toolkit regarding performant scroll-related events is the Intersection Observer API.

A great benefit to this API is avoiding the main thread, as Intersection Observer runs asynchronously, freeing up the main thread for everything else it already must do.

Intersection Observer can be used for many things, such as lazy loading, triggering advertisements or popups, scroll to top buttons, etc.

Here is an example where I use Intersection Observer to trigger an event that changes a website’s header. I place an element a certain amount of pixels below the visible viewport (with id=”pixel-to-watch”). When the element is scrolled to intersect the visible viewport, or out of the viewport, I add or remove a class to my header accordingly.

 const header = document.querySelector(".site-header");
 let observer = new IntersectionObserver((e => {
 	e[0].boundingClientRect.y < 0 ? header.classList.add("shrink") : header.classList.remove("shrink")
 }));
 observer.observe(document.querySelector("#pixel-to-watch"));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