DEV Community

Cover image for Forever Functional: Debouncing and throttling for performance
OpenReplay Tech Blog
OpenReplay Tech Blog

Posted on • Edited on • Originally published at blog.openreplay.com

Forever Functional: Debouncing and throttling for performance

by Federico Kereki

In many cases, you want to limit how often a function is called. Implementing such limits can lead to complex code, unless a functional technique is used. In this article, we'll show how we can debounce and throttle functions (don't worry! we'll explain this below!) by using higher order functions. Applying those techniques will give your code an immediate performance benefit, so they are good tools to know!

Debouncing? Throttling? Sounds dire...

Debouncing and throttling are two techniques that limit when and how often a function is called. There's an interesting symmetry:

  • debouncing a function means we wait a certain time, doing nothing, until we call the function.
  • throttling a function means we wait a certain time, doing nothing, after we call the function.

In both cases, we won't be calling the function in the "do nothing" periods. We'll show practical use cases for both situations below.

Before starting with the new algorithms, it's interesting to remember function memoization from [a previous article in this series]. With memoization, you modify a function but more drastically: it will get called just once and never more. With debouncing and throttling, we don't want to go that far: we wish to allow a function to do its thing again -- but we'll be restricting when this will happen, implementing some timeout between calls.

Debouncing functions

The concept of debouncing comes from electronics and involves waiting to activate something until specific stability has been reached. Let's have an everyday example for web apps: an autocomplete component. As the user types letter after letter, you would have to call a function to query an API to fetch possible options. However, you don't want to do it keypress by keypress, because there will be a lot of calls, and what's worse, you won't have any use for many of them -- you'll only care about the last one. The idea, then, is to debounce the calling of the function. You may call it as many times as you wish, but no actual API calls will go out until there has been some time without any further calls. This concept also works with events handlers: dealing with scroll or mouse movement events may quickly cause performance issues because the corresponding methods run very frequently. However, if you debounce the handlers, they will only run when there's a lull in events, avoiding jittery or "stuttering" effects. The following diagram explains the concept.

Debouncing

Orange marks represent events. After each event, a waiting period (in light green) starts, but it is interrupted by any new event. If the waiting period is completed with no interruptions, then the function is called (dark green). There may be any number of events, but the debounced function will run only after a specified pause. Let's see how to do this in JavaScript.

const debounce = (fn, timeDelay = 1000) => {
  let fnTimer;
  return (...args) => {
    clearTimeout(fnTimer);
    fnTimer = setTimeout(() => fn(...args), timeDelay);
  };
};
Enter fullscreen mode Exit fullscreen mode

When you debounce a function, you get a new one. This new function can be called as many times as you wish, but it will set a timer with a timeout. Every time you call the new function, the timer gets reset, and the timeout starts again. When the timeout is done (meaning, there were no calls for a time) the original function will get called. Simple!

An example follows. We have a doSomething(...) function that just logs its argument. We'll debounce it so it will only run if half a second (500 milliseconds) has elapsed since the last call. We will have a loop calling the debounced function repeatedly, with a timed wait between calls. (We'll use a sleep() function for those delays.) Usual delays will be short, 200 milliseconds (so less than half a second). Twice in the loop (at the 13th and 15th passes), we'll have longer delays (750 milliseconds), enough for the timeout for the original function to be over. After the last pass of the loop is done, the timeout runs out, and the original function is called for the last time.

const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay));

const doSomething = (value) => console.log(new Date(), "DOING SOMETHING", value);

const db = debounce(doSomething, 500);

for (let i = 1; i <= 30; i++) {
  console.log(new Date(), "CALLING #", i);
  db(i);
  await sleep(i === 13 || i === 15 ? 650 : 200);
}
Enter fullscreen mode Exit fullscreen mode

When we run this code, we get the following result:

2022-07-09T20:19:15.795Z CALLING # 1
2022-07-09T20:19:15.999Z CALLING # 2
2022-07-09T20:19:16.200Z CALLING # 3
2022-07-09T20:19:16.400Z CALLING # 4
2022-07-09T20:19:16.601Z CALLING # 5
2022-07-09T20:19:16.801Z CALLING # 6
2022-07-09T20:19:17.002Z CALLING # 7
2022-07-09T20:19:17.203Z CALLING # 8
2022-07-09T20:19:17.403Z CALLING # 9
2022-07-09T20:19:17.603Z CALLING # 10
2022-07-09T20:19:17.803Z CALLING # 11
2022-07-09T20:19:18.004Z CALLING # 12
2022-07-09T20:19:18.204Z CALLING # 13
2022-07-09T20:19:18.704Z DOING SOMETHING 13
2022-07-09T20:19:18.955Z CALLING # 14
2022-07-09T20:19:19.155Z CALLING # 15
2022-07-09T20:19:19.656Z DOING SOMETHING 15
2022-07-09T20:19:19.906Z CALLING # 16
2022-07-09T20:19:20.107Z CALLING # 17
2022-07-09T20:19:20.307Z CALLING # 18
2022-07-09T20:19:20.508Z CALLING # 19
2022-07-09T20:19:20.708Z CALLING # 20
2022-07-09T20:19:20.908Z CALLING # 21
2022-07-09T20:19:21.109Z CALLING # 22
2022-07-09T20:19:21.309Z CALLING # 23
2022-07-09T20:19:21.510Z CALLING # 24
2022-07-09T20:19:21.710Z CALLING # 25
2022-07-09T20:19:21.911Z CALLING # 26
2022-07-09T20:19:22.111Z CALLING # 27
2022-07-09T20:19:22.312Z CALLING # 28
2022-07-09T20:19:22.512Z CALLING # 29
2022-07-09T20:19:22.712Z CALLING # 30
2022-07-09T20:19:23.212Z DOING SOMETHING 30
Enter fullscreen mode Exit fullscreen mode

The original function (the one that prints "DOING SOMETHING") was only called three times: at times #13 and #15, when a long enough delay was provided, and at the end of the loop when no more calls were forthcoming. We achieved the debouncing requirement very easily. In an autocomplete component, we'd just call the debounced function for each keypress -- but no actual calls to the API would be made unless the user stopped typing for a while.

Throttling a function

There's a different situation that we also want to control. Imagine you have a form in which you enter some id, and when you click on a RETRIEVE button, an API call is made to a server to retrieve some data matching that id. If the user starts clicking on the button, again and again, lots of calls will go out -- but they will most probably receive the very same results. You could want to throttle the calls to the API so that the first call would go through, but then, for a certain time, the call wouldn't be repeated. Similar problems could happen in games: what if the player repeatedly clicks on the "fire" button, even if there's a cooling time between shots? Yet a third case: when implementing "infinite scrolling" you want to trigger the request for more data when you are scrolling down, but you don't want to check very often -- and you don't want to wait until the user reaches the bottom (as with debouncing) because that would be too late!

As we mentioned earlier, debouncing and throttling are similar - but with the former, you wait until doing something, and with the latter, you do something but wait until doing it again. The following diagram shows the difference.

Throttling

As with debouncing, events are marked as orange rectangles. When an event occurs, we instantly call the throttled function if we are not in a waiting period. However, if we happen to be in the timeout zone, we don't do anything; we ignore the event. No matter how frequently events occur, the throttled function will limit the number of calls.

How does this work? So, we'll have code that's similar for both. When you throttle a function, you get a new one. This new function, when called, will call the original function immediately, but it will set up a timer with a timeout. Whenever the function gets called, it will first check if the timer is active; if so, it won't do anything. When the timeout ends, the timer will be cleared, and calls to the original function will be allowed again.

const throttle = (fn, timeDelay = 1000) => {
  let fnTimer;
  return (...args) => {
    if (!fnTimer) {
      fn(...args);
      fnTimer = setTimeout(() => {
        fnTimer = null;
      }, timeDelay);
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

Let's try the same example from the previous section but apply throttling instead. We'll use the same function but throttle it to a call every 3000 milliseconds, three seconds.

const db = throttle(doSomething, 3000);

for (let i = 1; i <= 30; i++) {
  console.log(new Date(), "CALLING #", i);
  db(i);
  await sleep(i === 13 || i === 15 ? 750 : 200);
}
Enter fullscreen mode Exit fullscreen mode

If we run this loop, results will logically vary compared to debouncing.

2022-07-09T20:22:18.641Z CALLING # 1
2022-07-09T20:22:18.644Z DOING SOMETHING 1
2022-07-09T20:22:18.844Z CALLING # 2
2022-07-09T20:22:19.045Z CALLING # 3
2022-07-09T20:22:19.245Z CALLING # 4
2022-07-09T20:22:19.446Z CALLING # 5
2022-07-09T20:22:19.646Z CALLING # 6
2022-07-09T20:22:19.847Z CALLING # 7
2022-07-09T20:22:20.047Z CALLING # 8
2022-07-09T20:22:20.248Z CALLING # 9
2022-07-09T20:22:20.448Z CALLING # 10
2022-07-09T20:22:20.648Z CALLING # 11
2022-07-09T20:22:20.849Z CALLING # 12
2022-07-09T20:22:21.049Z CALLING # 13
2022-07-09T20:22:21.799Z CALLING # 14
2022-07-09T20:22:21.799Z DOING SOMETHING 14
2022-07-09T20:22:22.000Z CALLING # 15
2022-07-09T20:22:22.751Z CALLING # 16
2022-07-09T20:22:22.951Z CALLING # 17
2022-07-09T20:22:23.152Z CALLING # 18
2022-07-09T20:22:23.352Z CALLING # 19
2022-07-09T20:22:23.553Z CALLING # 20
2022-07-09T20:22:23.753Z CALLING # 21
2022-07-09T20:22:23.953Z CALLING # 22
2022-07-09T20:22:24.154Z CALLING # 23
2022-07-09T20:22:24.354Z CALLING # 24
2022-07-09T20:22:24.555Z CALLING # 25
2022-07-09T20:22:24.755Z CALLING # 26
2022-07-09T20:22:24.956Z CALLING # 27
2022-07-09T20:22:24.956Z DOING SOMETHING 27
2022-07-09T20:22:25.156Z CALLING # 28
2022-07-09T20:22:25.357Z CALLING # 29
2022-07-09T20:22:25.557Z CALLING # 30
Enter fullscreen mode Exit fullscreen mode

The very first time that the new function got called, it did call the original one. Afterward, there were many more calls, but the original function didn't get called again until three full seconds passed (in call #14). The same happened after call #27 because at least three seconds had passed since the previous call to the original function. No matter how often you call the throttled function, the original function will only get called once every three seconds.

Open Source Session Replay

OpenReplay is an open-source, session replay suite that lets you see what users do on your web app, helping you troubleshoot issues faster. OpenReplay is self-hosted for full control over your data.

replayer.png

Start enjoying your debugging experience - start using OpenReplay for free.

Throttling promises

Let's now consider a last case. We may be calling an external API, but we don't want to call it over and over again with the same parameters. (A simple example: you may call a weather API, and the provider only updates its data every 5 minutes, so you don't want to call it more frequently.) If we wanted to just call the API once and never call it again, we could do with promise memoization, as we saw in a previous article, but here we want calls to eventually go out again, after some time. We can marry promise memoization and throttling, and the following would be an implementation.

const promiseThrottle = (fn, timeDelay = 1400) => {
  let cache = {};
  let timers = {};                       // [1]
  return (...args) => {
    let strX = JSON.stringify(args);

    if (!(strX in timers)) {             // [2]
      timers[strX] = setTimeout(() => {
        delete cache[strX];
        delete timers[strX];
      }, timeDelay);
    }

    return strX in cache
      ? cache[strX]
      : (cache[strX] = fn(...args).catch((x) => {
          delete cache[strX];
          delete timers[strX]            // [3]
          return x;
        }));
  };
};
Enter fullscreen mode Exit fullscreen mode

Most of the code is exactly as in the previous article, with three differences:

  • at (1), we define a timers object because we will need many timeouts, one per promise. We don't want to throttle all calls, only repeated ones.
  • at (2), we check if a timeout already exists for this promise. If not, we set it up to run and delete both the cached promise and the timer after some time.
  • at (3), we delete both the cached promise and the timer (if any) in case of a problem with the API call.

Let's see how this works. I'll use the OpenWeather API to get the temperature for a city. Getting the temperature requires the following code; note that you'll have to get an API key of your own. I added console.log() calls to help understand how this works; in a real app, you wouldn't have that!

const API = "https://api.openweathermap.org/data/2.5/weather";

const KEY = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; // you need your own API key

const getTemperature = async (lat, lon) => {
  console.log("ACTUALLY CALLING API", lat, lon);
  const temp = await fetch(
    `${API}?lat=${lat}&lon=${lon}&units=metric&appid=${KEY}`
  ).then((data) => data.json().then((data) => data.main.temp));
  console.log(new Date(), "GOT TEMP", temp);
};
Enter fullscreen mode Exit fullscreen mode

Now we can do a test, repeatedly calling the API to get the temperature at Montevideo, Uruguay (my home city), and Pune, India (where I also lived for a time).

const debounceTemp = promiseThrottle(getTemperature, 3000);

const MVD_LAT = "-34.901112";
const MVD_LON = "-56.164532";

const PUNE_LAT = "18.516726";
const PUNE_LON = "73.856255";

for (let i = 1; i <= 30; i++) {
  console.log(new Date(), "LOOP AT ", i);
  if (i === 13 || i==15) {
    debounceTemp(PUNE_LAT, PUNE_LON);
  } else {
    debounceTemp(MVD_LAT, MVD_LON);
  }
  await sleep(333);
}
Enter fullscreen mode Exit fullscreen mode

I throttled the promise to only go out every 3 seconds; you would use a more extended time delay for a real app. The output of this loop is as follows.

2022-07-09T20:24:38.756Z LOOP AT  1
ACTUALLY CALLING API -34.901112 -56.164532
2022-07-09T20:24:39.155Z LOOP AT  2
2022-07-09T20:24:39.488Z LOOP AT  3
2022-07-09T20:24:39.831Z GOT TEMP 18.82
2022-07-09T20:24:39.832Z LOOP AT  4
2022-07-09T20:24:40.165Z LOOP AT  5
2022-07-09T20:24:40.499Z LOOP AT  6
2022-07-09T20:24:40.832Z LOOP AT  7
2022-07-09T20:24:41.165Z LOOP AT  8
2022-07-09T20:24:41.499Z LOOP AT  9
2022-07-09T20:24:41.832Z LOOP AT  10
ACTUALLY CALLING API -34.901112 -56.164532
2022-07-09T20:24:42.075Z GOT TEMP 18.82
2022-07-09T20:24:42.166Z LOOP AT  11
2022-07-09T20:24:42.499Z LOOP AT  12
2022-07-09T20:24:42.833Z LOOP AT  13
ACTUALLY CALLING API 18.516726 73.856255
2022-07-09T20:24:43.076Z GOT TEMP 22.08
2022-07-09T20:24:43.167Z LOOP AT  14
2022-07-09T20:24:43.501Z LOOP AT  15
2022-07-09T20:24:43.834Z LOOP AT  16
2022-07-09T20:24:44.168Z LOOP AT  17
2022-07-09T20:24:44.502Z LOOP AT  18
2022-07-09T20:24:44.835Z LOOP AT  19
ACTUALLY CALLING API -34.901112 -56.164532
2022-07-09T20:24:45.079Z GOT TEMP 18.82
2022-07-09T20:24:45.169Z LOOP AT  20
2022-07-09T20:24:45.503Z LOOP AT  21
2022-07-09T20:24:45.836Z LOOP AT  22
2022-07-09T20:24:46.170Z LOOP AT  23
2022-07-09T20:24:46.503Z LOOP AT  24
2022-07-09T20:24:46.837Z LOOP AT  25
2022-07-09T20:24:47.170Z LOOP AT  26
2022-07-09T20:24:47.504Z LOOP AT  27
2022-07-09T20:24:47.837Z LOOP AT  28
ACTUALLY CALLING API -34.901112 -56.164532
2022-07-09T20:24:48.082Z GOT TEMP 18.82
2022-07-09T20:24:48.171Z LOOP AT  29
2022-07-09T20:24:48.505Z LOOP AT  30
Enter fullscreen mode Exit fullscreen mode

Some details:

  • the first call (for Montevideo) goes out at the first step; the results come back soon afterward
  • the following calls for Montevideo do not produce a call
  • only after 3 seconds have passed, a new call for Montevideo goes out at step #10
  • when we call the API to get Pune's temperature at step 13, the call goes out immediately, as with our first call for Montevideo
  • a second call for Pune's temperature, at step 15, doesn't go out at all

With this mechanism, we avoid repeating calls, effectively throttling the promise.

Conclusion

With the techniques shown in this article, you can limit the frequency of calls to a given handling function, avoiding delays, stutters, and plain overloading of remote servers. The techniques are easily implemented (and libraries such as LoDash and Underscore provide such implementations) so these tools are valid ones for you to use!

newsletter

Top comments (0)