Every year I’ll come around these two words Debounce and Throttle. And somehow every time they managed to give me headache with their twisted similarities and differences. So I’d like to study them once again and hopefully this time put them in a much simpler way to remember.
Debounce
I happen to use debounce more often, so I kinda of remember the implementation by heart:
function debounce(fn, delay) {
let h
return function(...params) {
clearTimeout(h);
h = setTimeout(() => {
fn.apply(this, params)
}, delay);
}
}
Basically in the above functional programming, I convert the old function fn
to a new function so that when you call it repeatedly it would follow the debounce behavior. I will mention shortly what is the debounce behavior.
If I were not doing any transformation, I would write a verbose version:
function func(fn) {
return function(...params) {
fn.apply(this, params)
}
}
The above piece does nothing to the original function. After the debounce is applied, we don’t want to invoke the fn
right away. Instead, we setup a timer, and wait till it expires to then safely invoke the function. When the function is invoked repeatedly, it constantly delete the old timer and set up a new timer so the timer rolls forward.
There’s an easy-to-miss fact here. When we call the function first time, the timer h
is created and if we wait till the delay
finishes, the function gets executed. However, if we call the function within delay
, the timer h
will be reissued with the old one deleted from the memory. And if we wait till another delay
finishes, this function gets executed. Note, this function refers to the function called at the second time, which carries different params
as well as this
. What this essentially tells us is that, there’s no memory kept for the past (unsuccessful) calls as soon as a new call comes to the picture. We need to remember that! This is Debounce.
If we use the Elevator door analogy: We wait till everyone gets in the door before closing it. The door won’t care if this is the second or third time it tries to close. To it, what matters is whether there’s another person trying to get in now. In a sense, the door cares Now and the Future while throwing out the Past.
A summary for debounce is: no matter how quickly and how many times you call a function, we want to make sure after a delay
, a single call (the last call) is invoked afterwards.
Debounce can be implemented quite easily due to this lack of past memory. And in some real time design, such as hardware, people actually accomplish debounce by simply waiting.
Before we move to throttle, let’s take a look at another version of Debounce which we will use later to relate to Throttle. In this version, we first call the function and then wait for a delay
instead of the opposite. This is referred as leading or immediate version of Debounce.
function debounce(fn, delay) {
let h
return function(...params) {
if (!h) {
fn.apply(this, params)
} else {
clearTimeout(h)
}
h = setTimeout(() => {
h = null;
}, delay);
}
}
In the above code, when we try to invoke the function, we first check first if it’s available by looking at the timer. If it has expired as in !h
, we then call the function. Otherwise we reset the timer. Either way the timer rolls to the future, and only when it expires, h
then be set to null
to open up the availability again. So h
is programmed to serve two purposes here, to hold the timer handler as well as an availability flag (by applying a not
operator to it). Once again this version of debounce doesn’t carry memory with it. It only would act upon the latest called function.
Now let’s talk about Throttle and see how it’s related to our discussion so far.
Throttle
I only vaguely remember what is throttle by relating it to the gun firing. For instance, you only can fire the gun in certain rate, no matter how hard you try. Here’s a recent version that I drafted:
function throttle(fn, delay) {
let h
let queue = []
function pop() {
if (queue.length < 1) return
if (!h) {
const [that, args] = queue.pop()
fn.apply(that, args)
h = setTimeout(() => {
h = null
pop()
}, delay)
} else {
// timer reset is not here
}
}
return function push() {
queue.push([this, arguments])
pop()
}
}
There’re quite a few interesting thing in the above implementation of Throttle. First hard to miss thing is that we are keeping track of all past calls that haven’t been invoked by using a Queue, first come and first serve. Any call will be pushed into the queue first and then pop it based on availability.
The availability is once again driven by the timer h
. As long as there’s a timer that has not expired yet, it’ll block the execution, in our case, the queue needs to wait. When there’s no on-going timer, we then go to the queue and pop the first one in line and invoke it. And when the timer expires, we then make the process available by setting h
to null
. This pattern is 100% identical to the Debounce version above.
No kidding. Essentially this means: if we only want to fire a gun once without caring how many we miss to trigger in the past, we revert to the Debounce behavior. If somehow we don’t want to miss any trigger in the past, we need the Throttle behavior. In a way, Throttle is an add-on to Debounce with the memory feature. Can it be that simple?
Difference in the timeline
No, it’s not that simple. By comparing line by line, I realized there’s a small twist. Let’s first identify the pattern:
function invoke() {
if (!h) {
fn.apply(that, args)
h = setTimeout(() => {
h = null
}, delay)
} else {
...
}
}
The availability check !h
is same which leads to two possibilities, either available or not. For the available case, they both invoke the function and reset the timer, same. But when it’s not available to invoke, Throttle choose to do nothing while Debounce choose to reset the timer as well. This is the twist.
When it’s not available to call, Debounce makes an adjustment to the original timeline asking for more time (than delay
) to wait. But throttle says otherwise, it sticks to the original timeline and keeps the throttle rate
constant. This answers to another question, the debounce delay is not exactly same as throttle rate. But they can be the same in one edge case, if you uniformly call a function in the frequency of delay
, then both Debounce and Throttle give the same identical timeline!
There’s an open flaw in the Debounce case: if you constantly fire a function in a frequency faster than
delay
, then you only can make at most one call invoked (1 in the leading version, 0 in the normal version). Isn’t this weird? Maybe we should have an improved version of debounce based on what we learn from the throttle behavior.
Conclusion
Debounce and Throttle are indeed quite similar in implementation, especially in terms of the code pattern. However they are not exactly same, Throttle is not an add-on of Debounce, simply because Throttle fixes the throttle rate with each function calls, whereas Debounce can modify the delay time with each function calls.