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 โ˜•๏ธ