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.
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.
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)