Skip to main content

Command Palette

Search for a command to run...

Debounce and Throttle

Updated
5 min read
Debounce and Throttle
T

Just a guy who loves to write code and watch anime.

Introduction

I'm writing this post again because I had some difficulties in explaining the difference between debounce and throttle.

In human words:

Debounce -> "Wait until X ms have passed since the last call, then call the function".

Throttle -> "Call the function at most once every X ms".

They sound super similar, but they are not the same. Let's also implement both of them in TypeScript.

Debounce

A common use case for debounce is search. You search for a query, but don't want to make a request to the server on every keystroke. Let the user finish typing and then make a request in about 300ms.

function debounce(func: Function, delay: number) {
  let timeoutId: NodeJS.Timeout | null = null;

  return function (this: any, ...args: any[]) {
    const context = this;
    if (timeoutId) clearTimeout(timeoutId);

    timeoutId = setTimeout(function () {
      func.apply(context, args);
    }, delay);
  };
}

Before I explain the code, you would use it like this in practice:

function search(query: string) {
  console.log("Searching for", query);
}

const debouncedSearch = debounce(search, 300);

debouncedSearch("react");
debouncedSearch("redux");
debouncedSearch("react router");
debouncedSearch("typescript");

Spoiler: typescript will be the only one that will be called (last debouncedSearch call).

Going back to the code, the debounce function accepts a function and a delay. In return, it gives you a new function that whenever called, will delay the execution of the original function by the given delay.

The confusing part is likely this bit:

return function (this: any, ...args: any[]) {
  const context = this;
  if (timeoutId) clearTimeout(timeoutId);

  timeoutId = setTimeout(function () {
    func.apply(context, args);
  }, delay);
};

Let's start with the parameters. this in the parameter is a TypeScript feature that tells you the function is a method of an object. This isn't needed to be fair, but it's a good practice to include it. The reason I say it's not needed is because you can have anything in there. You can always do const context = this in JavaScript.

The second parameter is the arguments. When you do ...args, you're telling TypeScript that the function can take any number of arguments and group them in an array.

To be explicit in our code, we store the this context in a variable called context.

setTimeout returns an ID. If you want to cancel the timeout, you need to use that ID. Imagine you call debouncedSearch multiple times. You want to cancel the previous calls. That's why we check if timeoutId exists and we clear it to start fresh.

Lastly, this bit func.apply(context, args); is what actually calls the function. Here we call the original function with not just the correct arguments, but also the correct this context. This isn't always needed, but if we were given a method of an object where the method uses this, we need to make sure we pass the correct this context. In our search example, we wouldn't need it (we’re just making a request to the server).

Closure

The pattern of returning a function from another function is called a closure. It's powerful because every time you call the debounce function with different methods, each function that gets returned has its own timeoutid variable.

function debounce(func: Function, delay: number) {
  let timeoutId: NodeJS.Timeout | null = null;

  // Returned function will have access to `timeoutId`
  return function (this: any, ...args: any[]) {};
}

timeoutId is "closed over" by the returned function. That means that the returned function has access to the timeoutId variable.

Generally, closures is a common pattern where you need to manage "state" across multiple function calls.

Throttle

Throttle is different from debounce. Instead of clearing the timeout and letting the last function be the debounced run, throttle will run the function at the beginning and then again every X ms.

Throttle is like saying "Give me the amount of time I should not let anything happen. When called the first time, I run right away. When called the second time, if the time you gave me is not up, I do nothing."

Now, throttle could say two things:

  • "If you still call me during the cooldown time, I'll queue the last call you made."

  • "If you still call me during the cooldown time, I'll just ignore it."

Whether you want the first or second behavior is up to you. First one is quite common though to not miss the last event.

Simple implementation where we ignore the last call during the cooldown time:

function simpleThrottle<T extends Function>(func: T, limitMs: number) {
    let lastRun = 0;

    return function(this: any, ...args: any[]) {
        const now = Date.now();

        const timeElapsed = now - lastRun;

        if (timeElapsed >= limitMs) {
            lastRun = now;
            func.apply(this, args);
        }
    }
}

const handleEvent = simpleThrottle(eventHandler, 300);

Here, eventHandler is the function that will be called. limitMs is the amount of time we should not let anything happen.

We can check the time since the last call by using Date.now(). lastRun is the timestamp of the last call. First time this is never an issue because then now is timeElapsed (since lastRun starts as 0). Which is obviously much greater than limitMs.

Let's look at an example where we queue the last call:

function queuedThrottle<T extends Function>(func: T, limitMs: number) {
    let lastRun = 0;
    let timeoutId: NodeJS.Timeout | null = null;

    return function(this: any, ...args: any[]) {
        const now = Date.now();
        const context = this;

        const timeElapsed = now - lastRun;

        // Oh no, we're still in cooldown.
        if (timeElapsed < limitMs) {
            // Was something else queued?
            // Because we were called again, let's clear it.
            // We have a newer function to queue
            if (timeoutId) {
                clearTimeout(timeoutId);
            }

            // Example
            // limitMs = 300
            // timeElapsed = 200
            // timeRemaining = 300 - 200 = 100
            const timeRemaining = limitMs - timeElapsed;

            // Queue the new function
            // We want this to run after the cooldown time has passed
            // We know how much time is left by checking `timeRemaining`
            timeoutId = setTimeout(() => {
                lastRun = Date.now();
                func.apply(context, args);
            }, timeRemaining);

            return;
        }

        // If we're not in cooldown, execute immediately
        lastRun = now;
        func.apply(context, args);
    }
}