Debouncing and Throttling in JavaScript

Have you ever experienced your page getting very slow when you scroll down or run some animation?

When I used to work on the Canvas animation for dashboards, it required a lot of CPU resources just to represent the animation every second smoothly. Then one day, a new requirement was added: The tooltip should be shown once your mouse cursor hovers over the Canvas. This was really killing me. My CPU’s resources were so depleted that my computer was kind of frozen.

After a while, I learned a workaround for this problem: throttle or debounce. So in this article, I want to share what they are and why they are useful.

Prior to Reading

If you know how JavaScript’s asynchronous functions and HOF work under the hood, you could accept this theory more easily. You might be interested in my other articles about JavaScript events and the HOF.

An Example of the Problem

Let’s say you want to search a country name through the API call. There’s an input box on the screen in which you can put the strings of the country name. Once you put the strings in it, your application hits the API call to load all the countries that include your strings.

Here’s a short example made by CodeSandBox:

Try to search for any country.

For mobile users or lazy people like me, here’s what happens if you look for the countries:

This is image title

Request Count on the screen refers to how many HTTP requests you have sent to the server. Note that it’s increased every time you press the keyboard, including the backspace. This looks OK, but imagine the normal size of an application you work on or a big application such as Airbnb.

However, sending requests every single time from the keyboard or any user action is certainly too much. We need a workaround for this.

Where Debounce and Throttle Come From?

To understand what they really are, I think it’s better to tell you the idea behind the solution to the problem above.

The main problem was too many HTTP requests were sent. Let’s say you wanted to search “Canada.” But if your web page is kind of frozen due to the requests for “C,” “Ca,” “Can,” “Cana,” etc. I’m sure nobody would be happy. So the demand for reducing the requests came into the world.

Debounce and Throttle are just names for how you actually reduce the requests. The common thing between Debounce and Throttle is a simple concept: Once my finger presses the keyboard, don’t send any requests for a while until I say it’s OK.

This is image title
Photo by the author.

Look at the figure above. It represents what happens, depending on the cases. Normal sends a request every time. Throttle sends a request the first time during the period and never sends another request until the period is over. Once the period is over, it sends a new request again. Debounce takes a callback that will be invoked in milliseconds and only sends a request as long as no extra requests are added during the period. Look at the case when “n” of “Canada” is pressed. An extra request is added and the previous request added when “a” was pressed is now going to be ignored. With this logic, if all the requests were added within the period (some milliseconds), only the last one will be executed.

Deep Dive Into the Code — Throttle

OK, now let’s explore the code!

import axios from 'axios';
const search = async (city) =>
  await axios.get(`https://restcountries.eu/rest/v2/name/${city}`)

search is the function that sends the city using the sample city API. I used the real restful API for the test to make this example more realistic. All you need is to fill out city with whatever the city is. Try it out yourself.

Let’s check out the code first:

const throttle = (delay, fn) => {
  let inThrottle = false;

  return args => {
    if (inThrottle) {
      return;
    }

    inThrottle = true;
    fn(args);
    setTimeout(() => {
      inThrottle = false;
    }, delay);
  };
};

medium_throttle.js

This anonymous function is a HOF that returns another function. When this function is called for the first time, inThrottle is assigned to false. Then once the returned function is called again, inThrottle is set to true and the callback function (fn) is executed. Then inThrottle becomes false again in delay ms. In the meantime, while inThrottle is true, none of the execution of the returned function from throttle can be executed.

This is image title

Do you remember this? Even if you keep clicking the button, for example, Throttle doesn’t allow you to execute the function in a row unless inThrottle is false.

Then the new method for requesting would be like this:

const sendRequestThrottle = throttle(500, search);
<input type="text" onChange={sendRequestThrottle} />

But, there’s one thing you need to be careful about.

The HOF method throttle should not be included in the React component. When state variables that are in component A are changed, A is re-rendered and everything is re-assigned again. If throttle stays re-rendered, inThrottle will also be re-assigned to false.

Deep Dive Into the Code — Debounce

Now let’s look at the Debounce. This code is quite similar to Throttle’s, so you should understand this part easily as well:

const debounce = (delay, fn) => {
  let inDebounce = null;
  return args => {
    clearTimeout(inDebounce);
    inDebounce = setTimeout(() => fn(args), delay);
  }
}

medium_debounce.js

That’s all. It’s super simple. The only difference with Throttle is that Debounce doesn’t check if inDebounce is true or whatever. If the callback is executed within a certain time period, it cancels the previous setTimeout that was going to be run soon and creates a new one. So if you keep pressing the keyboard fast enough, your callback would never be run.

This is image title
Now you’ll understand much better why Debounce’s calls look like the figure above.

All you need to do is to register your callback in the DOM:

const sendRequestDebounce = debounce(500, search);
<input type="text" onChange={sendRequestDebounce} />

Throttle and Debounce in Lodash

In most cases, you wouldn’t need to make a Throttle or Debounce because there are so many good lightweight libraries out there for these features. Lodash is also one of them.

function debounce(func, wait, options) {
  let lastArgs,
    lastThis,
    maxWait,
    result,
    timerId,
    lastCallTime

  let lastInvokeTime = 0
  let leading = false
  let maxing = false
  let trailing = true

  // Bypass `requestAnimationFrame` by explicitly setting `wait=0`.
  const useRAF = (!wait && wait !== 0 && typeof root.requestAnimationFrame === 'function')

  if (typeof func !== 'function') {
    throw new TypeError('Expected a function')
  }
  wait = +wait || 0
  if (isObject(options)) {
    leading = !!options.leading
    maxing = 'maxWait' in options
    maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait
    trailing = 'trailing' in options ? !!options.trailing : trailing
  }

  function invokeFunc(time) {
    const args = lastArgs
    const thisArg = lastThis

    lastArgs = lastThis = undefined
    lastInvokeTime = time
    result = func.apply(thisArg, args)
    return result
  }

  function startTimer(pendingFunc, wait) {
    if (useRAF) {
      root.cancelAnimationFrame(timerId)
      return root.requestAnimationFrame(pendingFunc)
    }
    return setTimeout(pendingFunc, wait)
  }

  function cancelTimer(id) {
    if (useRAF) {
      return root.cancelAnimationFrame(id)
    }
    clearTimeout(id)
  }

  function leadingEdge(time) {
    // Reset any `maxWait` timer.
    lastInvokeTime = time
    // Start the timer for the trailing edge.
    timerId = startTimer(timerExpired, wait)
    // Invoke the leading edge.
    return leading ? invokeFunc(time) : result
  }

  function remainingWait(time) {
    const timeSinceLastCall = time - lastCallTime
    const timeSinceLastInvoke = time - lastInvokeTime
    const timeWaiting = wait - timeSinceLastCall

    return maxing
      ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
      : timeWaiting
  }

  function shouldInvoke(time) {
    const timeSinceLastCall = time - lastCallTime
    const timeSinceLastInvoke = time - lastInvokeTime

    // Either this is the first call, activity has stopped and we're at the
    // trailing edge, the system time has gone backwards and we're treating
    // it as the trailing edge, or we've hit the `maxWait` limit.
    return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
      (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait))
  }

  function timerExpired() {
    const time = Date.now()
    if (shouldInvoke(time)) {
      return trailingEdge(time)
    }
    // Restart the timer.
    timerId = startTimer(timerExpired, remainingWait(time))
  }

  function trailingEdge(time) {
    timerId = undefined

    // Only invoke if we have `lastArgs` which means `func` has been
    // debounced at least once.
    if (trailing && lastArgs) {
      return invokeFunc(time)
    }
    lastArgs = lastThis = undefined
    return result
  }

  function cancel() {
    if (timerId !== undefined) {
      cancelTimer(timerId)
    }
    lastInvokeTime = 0
    lastArgs = lastCallTime = lastThis = timerId = undefined
  }

  function flush() {
    return timerId === undefined ? result : trailingEdge(Date.now())
  }

  function pending() {
    return timerId !== undefined
  }

  function debounced(...args) {
    const time = Date.now()
    const isInvoking = shouldInvoke(time)

    lastArgs = args
    lastThis = this
    lastCallTime = time

    if (isInvoking) {
      if (timerId === undefined) {
        return leadingEdge(lastCallTime)
      }
      if (maxing) {
        // Handle invocations in a tight loop.
        timerId = startTimer(timerExpired, wait)
        return invokeFunc(lastCallTime)
      }
    }
    if (timerId === undefined) {
      timerId = startTimer(timerExpired, wait)
    }
    return result
  }
  debounced.cancel = cancel
  debounced.flush = flush
  debounced.pending = pending
  return debounced
}

medium_loadsh_debounce.js

The code is quite long, but look at the debounced function, which is a HOF:

function debounce() {
  let lastCallTime;
  ...
  function debounced(...args) {
    ...
  }
  return debounced;
}

debouncehof.js

debounce returns a new function (debounced) that is executed indeed. Inside debounced, lastCallTime is used. So you may notice that debounce in Lodash might compare the previous time (which the function was previously called) with the current time (which the function is currently called).

To make this strategy a bit simpler, the code might look like this:

const throttled = function(delay, fn) {
    let lastCall = 0;
    return (...args) => {
        let context = this;
        let current = new Date().getTime();
        
        if (current - lastCall < delay) {
            return;
        }
        lastCall = current;
        
        return fn.apply(context, ...args);
    };
};

medium_lodashlike_throttle.js

Well, you could create your own debounce if you want, but just remember that the important thing is to postpone the function execution to the later point.

Conclusion

Debounce and Throttle both came from the need to delay the function execution because users didn’t want too many HTTP requests to be made. Nowadays, these are important ways to improve web performance. You can use either of them for any tasks that you want to delay, such as scrolling events.

#javascript #programming

Debouncing and Throttling in JavaScript
19.95 GEEK