Written: February 14, 2023
Edited: February 15, 2023
I wrote this article because debounce and throttle are super hard to keep straight and visualize in your head. I hope this will help you in your learning journey.
Back when I was trying to learn the difference between what a debounce
function (like this one from lodash) and throttle
function were (again, here's one from lodash), it turned out to be annoying to find materials that were crystal clear on exactly how they functioned.
I decided to write an article to correct that for you so you don't suffer the same fate! ๐
The main source of confusion is that, at face value, the two of them seem to behave a bit the same. If you're here reading this, I'm sure you've noticed the same thing.
What we're going to do is take them one at a time and walk through some real code examples that will show you exactly what they do (and how they're different).
By the end, you'll be a pro.
Sound good?
Nice. If you want to follow along with live code, check out this CodeSandbox link.
Let's get started.
At it's core, debounce
is a process for calling a function exactly once for any group of calls (the term debounce
was coined by John Hann).
A debounce
function is often used when a user does a lot of some sort of action and we only want to act on that when they've finished; a good example of this might be when a user is entering in text into a search input. We don't want to do a search each time they type a character, so we wait until they're done typing before firing off a request for data.
Here's a nifty little diagram that might explain this concept:
For instance, let's say we have this simplified debounce
function written in TypeScript:
const TIMEOUT_MS = 250; function debounce(callback: (...args: unknown[]) => unknown) { let timeoutId: ReturnType<typeof setTimeout> | null = null; return (...args: unknown[]) => { if (timeoutId) { clearTimeout(timeoutId); } timeoutId = setTimeout(() => callback(...args), TIMEOUT_MS); }; }
I know, that's a lot to digest. But we're going to walk through it together.
First off, we've defined a variable called TIMEOUT_MS
. A debounce
(and also throttle
) function you'd actually use in a real app would typically allow you to set your own timeout, but we're just hard-coding it here to make things easier. ๐๐ป
Next, we have our very own custom debounce
function. For its parameter, notice that it takes in a callback
function. We don't know what this function will be, so we've defined it to take in any number of args
of unknown
type and returns an unknown
type (we might want to change this for a production application, but it works for us here):
function debounce(callback: (...args: unknown[]) => unknown) { // ... code goes here }
This code has more to do with TypeScript than learning about our debounce
function, so let's move on (although you can read more about TypeScript here).
This callback
function we're taking in as a function parameter is what we're going to debounce
! Nice, right?
Next, we have this line of code which will track a timeoutId
:
let timeoutId: ReturnType<typeof setTimeout> | null = null;
Don't get too hung up by the TypeScript types here. All this is doing is declaring a timeoutId
variable, saying it can either by the type of the returned value of a setTimeout
call (which returns a number
ID for the timeout) or null
, and then we initialize the variable to null
.
The next thing we're going to do is return a function with the guts of our logic:
return (...args: unknown[]) => { // If we've previously set a timeoutId, that means we've called // this function within the timeout time limit if (timeoutId) { clearTimeout(timeoutId); } // Whether or not we clear the timeoutId above, set new timeout timeoutId = setTimeout(() => callback(...args), TIMEOUT_MS); };
The first thing it's doing is checking if timeoutId
is truthy (e.g. not null
, which was its initial value). If it is, that means our debounce
has previously been called. We will go ahead and clear the timeoutId
for reasons that will become apparent in the next few sentences.
Next, we want to call setTimeout
and assign the return value to timeoutId
. This setTimeout
will call callback
whenever it gets a chance to time out (which should happen after 250
milliseconds which we set earlier as TIMEOUT_MS
).
Whew.
This was a lot.
The TLDR for the code above is that we're clearing our previously-set timeout each time we call our debounce
-ed function. If we go long enough (250
milliseconds) without a call to our debounce-ed
function, we'll finally get a chance to call our callback
function.
Let's walk through an example of it being used.
First, let's go ahead and set up our debounce
function:
const handleDebouncedLog = debounce(() => console.log("Hello World!"));
This passes in a callback
function into debounce
and returns an anonymous arrow function as we saw before.
From here, we can write a quick loop to run 100 times and call our handleDebouncedLog
function each time:
for (let i = 0; i < 100; i++) { handleDebouncedLog(); }
If we were to check the dev console, we'd see exactly one log of "Hello World!" when this code was done running.
This is because we don't allow our timeout to finish when we quickly and repeatedly call our debounce
-ed function. Each time it's called, it's clearing out the previous timeoutId
and then setting a new one. It's only allowed to finish at the end when our 250
millisecond TIMEOUT_MS
time runs out.
Make sense? Nice.
Let's move on to throttle
.
I have good news for you! If you were able to make it through the debounce
section with your sanity intact, you're home free.
The throttle
function is, by comparison, really easy to grasp. This is because throttle
merely calls a callback
function exactly once within a time span (which we'll set using our TIMEOUT_MS
variable).
If that sounds the same as debounce
, well... hang with me. Let's discuss it.
A throttle
fuction is basically saying to a callback
function it's given, "Hey. You're doing too much. Let's just dial that activity back to one function call every X amount of time to tone things down a little bit."
Does that make sense?
Another way of thinking about it is that using throttle
is like a stoplight that only lets a single car through for every X amount of time (even if there are thousands of cars that tried to get through the intersection at the same time).
A picture is worth a thousand words, so here's another one:
And, right on schedule, here's a simplified TypeScript example of one of these bad boys:
const TIMEOUT_MS = 10; function throttle(callback: (...args: unknown[]) => unknown) { let timeoutId: ReturnType<typeof setTimeout> | null = null; return (...args: unknown[]) => { if (timeoutId) { return; } timeoutId = setTimeout(() => { callback(...args); timeoutId = null; }, TIMEOUT_MS); }; }
The first part of this function is set up exactly like before (our TIMEOUT_MS
variable, callback
function parameter, and timeoutId
variable). We'll skip over those.
I've hard-coded the timeout milliseconds to be 10
this time. That's to reflect the fact that throttle
is often used to juuuust barely take the edge off some user-initiated action (like moving a mouse or typing into an input) which we want to not run as often.
However, this is where our throttle
differs from debounce
. We just return early if we have a timeoutId
set already inside the anonymous arrow function that we're creating and returning:
return (...args: unknown[]) => { // This ensures we return if we've previously called the // function to kick off the timeout if (timeoutId) { return; } // Else, we set the timout which will eventually call our // callback and reset timeoutId to null timeoutId = setTimeout(() => { callback(...args); timeoutId = null; }, TIMEOUT_MS); };
Wait. What? ๐คจ
I know. It seems a bit off. But what we're essentially doing is building a function that will set a timeout and save a timeoutId
the first time it runs. Thereafter (until the timeout finishes 10
milliseconds later and calls the callback
), any further attempts to call our throttle
-ed function will merely return and not run.
This works like a charm, and it means that we are guaranteed that our throttle
-ed function will only be called once per TIMEOUT_MS
.
Once again, let's walk through a quick example of this code in action. First, we create our throttle
-ed function:
const handleThrottledLogger = throttle(() => console.log("Hello World!"));
Next, we're going to write a funky little loop that will call this function 20
times.
for (let i = 0; i < 20; i++) { setTimeout(() => { handleThrottledLogger(); }, i); }
All this is doing is staggering our calls by i
milliseconds. We're setting 20
timeouts here (each one staggered by 1
millisecond so they run one after another).
If we were to run this code and check our dev console, we'd see exactly 2
logs of "Hello World!"
. Can you think of why?
That's right! It's our throttle
function in action. We're only allowing one function call to get through once every 2
milliseconds.
This is exactly how a throttle
-ed functions works. It drastically decreases the rate at which the callback
function inside it can be called.
Now that you have a great idea of the difference between debounce
and throttle
, you can go one step further.
What happens if you want the function call at the end of your throttle
-ed function to run instead of the start (called the "trailing edge" call rather than the "leading edge" which is what this article talked you through)?
What happens if you're debounce
-ing a function for user input on a search bar and you want the eventual function call to run with the newest data from the last function call (again, "trailing edge" versus "leading edge")?
These are all possible with code, and I have a sneaking supicion that you'll be able to implement them now!
In any case, I hope this article helped you grasp the differences between these two types of functions.
As always, thanks for reading my articles.
Nathan โ๏ธ