You'll often want to schedule work to happen in response to browser events.
For example, imagine every time the user scrolls to the bottom of their feed, the site load more posts.
Infinite scrolling example by Corbacho (@dcorb)
The thing is, if you're not careful, you can harm the responsiveness of your site. And in the ever-evolving world of web-standards, it can be hard to say up to date with best practices.
Let's take a look at what we can do in 2021 to "throttle", "debounce", or otherwise limit function calls in response to browser events and maintain a great user experience (UX).
Recommended background readings:
- Alexander Farkas' StackOverflow comment
- Nolan Lawson's A tour of JavaScript timers on the web
- David Corbacho's Debouncing and Throttling Explained Through Examples
- Chris Coyier's The Difference Between Throttling and Debouncing
- Paul Lewis's Using requestIdleCallback
- Paul Lewis' Leaner, Meaner, Faster Animations with requestAnimationFrame
What makes webpages feel fast or slow
A webpage's performance in part depends on its ability to maintain 60 frames per second (FPS). A page is rendered one frame at a time. When the FPS reaches 60, the experience on the page is buttery smooth. When it drops bellow 60 FPS, the page will start feel slow.
By loading all those new posts as the user scrolls, you could overburden the browser's main thread. This would limit its ability to maintain rendering at 60FPS and make the page feel unresponsive.
In other words, if you do too much at once in response to an event you end up with bad UX.
Using requestAnimationFrame
to run callbacks between frame paints
Thankfully, there are ways to schedule the work so the main thread doesn't get overburdened. You can tell the browser its cool to wait until it isn't so busy before getting back to work.
Traditionally, this was done with the window.requestAnimationFrame(callback)
, or rAF
, method.
This method tells the browser you want to run a callback
, cb, function before the start of the next frame's paint. rAF
won't be called more or less times than the browser calculates the layout of the page, or roughly every 16.667 milliseconds.
$(window).on("resize", () => {
requestAnimationFrame(() => {
updateFeed($feed); // cb that perform some DOM modifications
});
});
Generally speaking, it makes sense to throttle most things with rAF
because you wouldn't request layout changes from the browser more often than it renders the layout. However, many browser events are already synced to the browser's rendering of the page, like scroll
.
So, instead, rAF
is used to move layout modification to the end of a frame. This avoid invalidating the DOM layout before you've had a chance to finish running all the logic inside the same frame.
rAF
has some shortcomings however. For starters rAF
doesn't know when a user is done interacting with the page.
So while it's nice to know your callback
will get called before every frame repaint, sometimes you want it to wait a little longer. Ideally, you'd schedule the cb
to run when nothing else is happening.
Debouncing callbacks for improved performance
But just how much performance gain is there really from using a debounce?
Bellow, you can see David Corbacho neat visual demonstration of the difference between debouncing and not debouncing cb
calls.
Trailing debounce example by Corbacho (@dcorb)
You'll not that with the debounce, far fewer callback
fires happen. In an scenario where you don't need to react to every single event, this means you've saved a ton of computation time!
Using lodash
to debounce
or throttle
rAF
Thankfullylodash
's _.debounce()
, which you can use in standalone fashion if you don't want to add it as a dependency, makes implementing debounces easy.
The _.debounce
method "enforces that a function not be called again until a certain amount of time has passed without it being called".
So we could do something like making the page wait until 400 milliseconds have gone by between updateFeed
calls before trying to update the feed.
const cb = () => {
updateFeed($feed);
};
const waitMs = 400;
$(window).on(
"resize",
_.debounce(() => requestAnimationFrame(cb), waitMs)
);
Using requestIdleCallback
to run cb
only when event-loop isn't busy
While the previous performance gains are great, we could ideally leverage all the information the browser has about the event loop. This way, we could implement a solution that doesn't run a cb
unless there's some free computation time.
This is where the window.requestIdleCallback(callback[, options])
, or rIC
, method comes in.
It runs your callback
only after the user is done doing stuff.
To do this ourselves, we'd have to attach an insane amount of event listeners to the DOM telling us what the user is up to and when. Then, when they go quiet, we could try and run our cb
hoping they're really done interacting with the page.
rIC
gives us a foolproof way to run a callback
only when there's free time in a frame. Typically, this happens when the user is inactive.
In our news-feed example, this means that the new posts would only be requested when the user is done scrolling, clicking a button, or otherwise milling about on our page.
const update = () => updateFeed($feed);
const cb = () => requestAnimationFrame(update);
const waitMs = 400;
$(window).on(
"resize",
_.debounce(() => requestIdleCallback(cb), waitMs)
);
That's all well and good, but what if the user is doing a bunch of things on the page immediately after scrolling to the bottom? Won't the scheduled news-feed update be delayed endlessly?
That's where rIC
's timeout
property comes in. You can define a maximum amount of time requestIdleCallback
can wait before your callback
is added to the event-loop.
So in our news-feed example, if we told rIC
it was only allowed to wait {timeout: 2000}
, after at most 2 seconds our cb
would run and feed update would be queued in the event-loop.
const update = () => updateFeed($feed);
const cb = () => requestAnimationFrame(update);
const timeout = { timeout: 2000 };
const waitMs = 400;
$(window).on(
"resize",
_.debounce(() => requestIdleCallback(cb, timeout), waitMs)
);
You'll note that even when we use a combination of
rIC
andrAF
, we still need to debounce and control how oftenrIC
can be called to limit the impact on the event-loop.
Making super-duper sure your callback runs on the first event fire
Sometimes you want a callback
to run on the first event fire. Using the default _.debounce
behavior, this won't happen. The cb
won't be called until the events have stopped firing.
To override lodash
's default _.debounce
behavior, you can make use of the optional leading
flag. This tells _.debounce
to invoke the callback
immediately but not invoke it again unless waitMs
time has gone by.
const update = () => updateFeed($feed);
const cb = () => requestAnimationFrame(update);
const rIC = () => requestIdleCallback(cb, timeout);
const timeout = { timeout: 2000 };
const waitMs = 400;
$(window).on("resize", _.debounce(rIc, waitMs, { leading: true }));
Again, David Corbacho has an excellent animation illustrating how this works.
Trailing debounce example by Corbacho (@dcorb)
Debounce wisely
Using rAF
and rIC
in combination with _.debounce
is a great way to run logic in response to browser events without harming UX. But it doesn't come without pitfalls.
For example, rIC
calls are not an ideal place to preform DOM modifications, since the DOM reference at invocation could already have been invalidated. Moreover, rIC
support in even modern browsers varies significantly, where even Chrome doesn't quite behave the way you'd expect.
But for a good amount of use cases out there (infinity-scrolling, update-on-resize, parallax, etc) this combination can and should be used.