Debouncing in JavaScript for Everyone
In this article, the concept of Debouncing is explained in a simplified way. We'll go step by step and visualize every concept thoroughly.
Before learning anything, we must understand its use and the reason for existing.
So what's the reason we have the concept of Debouncing?
We often need to do some expensive computations in Javascript repeatedly but don't want to do them at very high frequencies. Debouncing is a strategy to limit the frequency of expensive computations.
Examples and scenarios -
Let's say there's an input field. We want to search and display some content on a page by fetching the content from a backend API based on the input field's text as a search query. If a user writes 10 characters in the input field very fast, the API call will be made after every character is typed. Ten API calls will be made but most of the calls are redundant and not needed.
Example - If a user types "New York", then API calls will be made to"N"
,"Ne"
,"New"
,"New "
,"New Y"
,"New Yo"
,"New Yor"
,"New York"
and so on. In this case, we only want to call the API when the user has paused typing for some time. Ideally, we should only call the API after"New"
and"New York"
as there's some delay after typing"New"
and typing stopped at"New York"
.When we listen to some events like resize, scroll, or when the mouse pointer is moved. They fire too many events in every intermediate step. We don't want to call some expensive callback function in every intermediate step. We want to call the callback function in some discrete points where there is some pause.
Prerequisites
If you are already familiar with the concepts explained below, you can skip them or just have a look to recap the concepts.
Functions are first class objects
Functions in JavaScript are the first class objects, which means JavaScript functions are just a special type of object, which can do all the things that regular objects do like strings, arrays, etc. We can do the following with the functions in JavaScript - pass as a parameter to another function, return the function from another function, assign it to a variable, etc
function outer() {
let outerText = "Stewie"
function inner(text){
console.log(text, outerText)
}
return inner
}
let funcRef = outer()
funcRef("Hello")
// Output will be "Hello Stewie"
In the above code, there is an outer()
function and an inner()
function. While calling the outer()
function, it simply returns the inner()
function's reference that we save in a variable funcRef()
. This will be simply like calling inner()
with its arguments.
Everything looks good till now, but one thing to notice - While calling funcRef()
it looks normal to get access to outerText
variable. If you observe closely, we're not calling inner()
inside the outer()
function's code block, we're returning inner()
function's reference from there. When outer()
is called and the moment it returned the inner()
function's reference, the execution context of outer
is removed. We're able to access outerText
from funcRef
(which is ultimately calling the inner()
function) because of closures.
I just wanted to point this out or it gives an illusion of accessing data without embracing closures. I'll writeabout closures in some other article.
Callback function
A callback function in javascript is a function that is passed in another function as an argument to be called later.
function squareOfEven(evenNumber){
console.log(evenNumber*evenNumber)
}
function outerFunction(callback, someNumber){
if (someNumber % 2 == 0){
callback(someNumber)
}
}
// Calling the function
outerFunction(squareOfEven, 20)
outerFunction(squareOfEven, 15)
// Output will be 400 for the 1st outerFunction call and nothing for 2nd call
In the above code, squareOfEven()
is a callback function that is not called directly and passed to outerFunction()
as an argument. outerFunction()
calls squareOfEven()
inside its code block when needed, so squareOfEven()
is a callback function.
setTimeout and clearTimeout
setTimeout - setTimeout()
function is a web API that is used to execute a piece of code or a function after a specific delay.
Syntax - setTimeout(() => function(), delay)
setTimeout()
is used in some other ways also. You can read it in detail here
function printHello(){
console.log("Hello World")
}
setTimeout(() => printHello(), 5000)
We're passing an arrow function returning printHello()
and setting a delay of 5000ms (delay in setTimeout()
is in milliseconds). setTimeout()
will call printHello()
after 5000ms, hence "Hello World" will be printed after 5 seconds.
clearTimeout - In the above code we have set a timer to call printHello()
after 5 seconds. If the situation changes and we want to clear the timer such that printHello()
is not called even after 5000ms has passed and it's time to execute the printHello()
function; we use clearTimeout()
for this.
Every setTimeout()
has an id associated with it through which we can identify a particular timer. If there are 5 timers in our code then every timer has a different id.
We simply need to get the timer id and pass it in clearTimeout()
to remove it.
function printHello(){
console.log("Hello World")
}
// Getting timer ids
const timerId1 = setTimeout(() => printHello(), 5000)
const timerId2 = setTimeout(() => printHello(), 7000)
clearTimeout(timerId2)
In the above code, we created two timers of 5 seconds and 7 seconds each, then saved the ids of both timers in a variable. Later we used timerId2
to clear the second timer, so this code will only execute printHello()
once after 5000ms.
If you pass an id to clearTimeout()
which doesn't exist, it'll not throw any error or complain about it. It'll simply be ignored as if nothing happened. (Remember this point, it's very important)
setTimeout()
and clearTimeout()
is not as simple as this, but for our current use case, it's enough to know this much. I'll write an article on deep-dive of setTimeout
later.
Rest and Spread operator
Rest parameter syntax helps in calling a function with any number of arguments, no matter how it is defined.
function multiplyAll(...args){
let finalValue = 1
for(let item of args){
finalValue = finalValue*item
}
console.log(finalValue)
}
multiplyAll(2, 3, 5) // 30
multiplyAll(5, 8, 2, 10) // 800
multiplyAll(2, 1, 5, 3, 4, 6) // 720
In the above code multiplyAll()
takes any number of arguments and stores them in an array named args
. The name you put in place of arg
after 3 dots in the function definition, that'll be the name of the array inside multiplyAll()
.
Spread syntax is used to pass an array as an argument after spreading it. The above function multiplyAll accepts any number of arguments, but it accepts them in comma separated way. How to pass an array of indefinite length?
const nums = [2, 5, 3, 4]
multiplyAll(nums[0], nums[1], nums[2], nums[3]) // 120
The above code is a way to call myltiplyAll
with array data, but it's a very bad way to call the function by passing each value one by one. Imagine, we have arrays of lengths 100, 1000 and so on, how would we pass that? Here spread syntax comes to the rescue. See the code below.
const nums = [2, 5, 3, 4]
const nums2 = [3, 4, 2, 5, 6, 3, 7]
multiplyAll(...nums) // 120
multiplyAll(...nums2) // 15120
We just passed the array after spreading it. ...
is a really powerful operator.
Call and Apply methods
The methods call, apply and bind needs a lot of other prerequisites to dive deep into them, but here I'm going to explain it in a way that is relevant to debouncing and later write an article explaining these methods thoroughly.
The call()
method calls a function with a given this
value and arguments are provided individually. The apply()
method is also the same, the only difference is that it accepts arguments as an array.
function getName(){
console.log(this.name)
}
getName()
// undefined
In the above getName()
function we're accessing this
inside getName()
, the value of this
inside any function depends on how the function is invoked. If the function is a method of a class and invoked with the object of that class then this is set to that object inside the method, and when the function is called without any object then the value of this
is the global object which is window
object in the browser, that's why the value of this.name
is undefined
as there's no name
property in window
.
class Animal{
constructor(animalType){
this.type = animalType
}
getAnimalType(){
console.log(this.type)
}
}
animal = new Animal("Lion")
animal.getAnimalType() // Lion
In the above code, we're able to access the "Lion" inside getAnimalType
method because getAnimalType()
method is called with an object of the same class defining the method, so this
inside getAnimalType()
is the object animal
itself, hence we can access the property type
from the animal object inside getAnimalType()
.
You might think, why not just create an object with name
property and call the function with the object and access the object inside the function as this
; but it's not possible to do that.
function getName(){
console.log(this.name)
}
obj1 = { name : 'javascript' }
obj1.getName() // TypeError: obj1.getName is not a function
In the above code, we just created a random object obj1
with a name
property and tried to call the function getName()
with it. This throws an error because obj1
is not an object from any class and getName()
is not a method of any class.
By using call()
and apply()
, we can get this kind of behaviour from any object and function which is not a part of any class.
function getName(){
console.log(this.name)
}
obj1 = { name : 'Javascript' }
getName.call(obj1) // Javascript
getName.apply(obj1) // Javascript
In the above code, we're calling the function getName()
with obj1
by using call
and apply
, so it simply sets the context
or this
inside the function to obj1
and hence we can access the name
property of the object obj1
. The condition here is that the object we're passing in call()
or apply()
must have the property which we're accessing.
Now, what should we do if the function getName()
accepts some arguments. We can't just pass them because we can't call getName(arg1, arg2, ...)
like this.
Syntax of call
- functionName.call(context, arg1, arg1...)
Syntax of apply
- functionName.apply(context, [arg1, arg2, ...])
function getName(age, hobby){
console.log(this.name, age, hobby)
}
obj1 = { name: 'Javascript' }
getName.call(obj1, 40, 'swimming') // Javascript 40 swimming
getName.apply(obj1, [40, 'swimming']) // Javascript 40 swimming
This is the way to pass arguments to getName()
by using call()
and apply()
.
The difference is that call()
accepts arguments one-by-one and apply()
accepts them as an array.
Here's an illustration -
We have understood all the prerequisites for debouncing, now let's understand the concept of debouncing.
Debouncing
// Logic of debounce
function debounce(callbackFn, delay) {
let timerId = null
function debouncedFn(...args) {
clearTimeout(timerId)
timerId = setTimeout(() => {
callbackFn.apply(null, args)
}, delay)
}
return debouncedFn
}
The above debounce()
function accepts a callback function callbackFn()
and delay
as arguments and calls the callbackFn()
after the delay
. callbackFn()
is the expensive function which we want to avoid calling frequently. So instead of passing the expensive function directly to event listeners with frequent event fires, we pass the debounced version of the function to the event listeners.
Example -
// Hypothetical expensive function
function someExpensiveFunction(){
console.log("Hello World")
}
window.addEventListener('resize', someExpensiveFunction)
If we pass someExpensiveFunction()
directly to the event listener which listens to resize
event, then it'll be called every time the resize event fires.
When running the code in console, it executes someExpensiveFunction()
135 times (it can be any high value for your case). This is very bad if you're running something expensive inside the function.
In the above code, resize event is fired 135 times, but we don't want to call the someExpensiveFunction()
frequently like this.
The above image shows what is happening currently.
Let's understand how debounce()
function solves the issue of calling someExpensiveFunction()
every time resize event fires.
// Logic of debounce
function debounce(callbackFn, delay) {
let timerId = null
function debouncedFn(...args) {
clearTimeout(timerId)
timerId = setTimeout(() => {
callbackFn.apply(null, args)
}, delay)
}
return debouncedFn
}
// Hypothetically expensive function
function someExpensiveFunction() {
console.log("Hello World")
}
// Debounced version of someExpensiveFunction with delay of 500ms
const debouncedFunction = debounce(someExpensiveFunction, 500)
// Setting debouncedFunction in event listener
window.addEventListener('resize', debouncedFunction)
We get a debounced version of someExpensiveFunction()
as debouncedFunction()
and pass it to the event listener. This calls debouncedFunction()
every time the resize event fires, but it doesn't call someExpensiveFunction()
every time.
Let's go step by step -
We pass
someExpensiveFunction()
and delay of500
milliseconds todebounce()
function and saves the returned debounced version ofsomeExpensiveFunction()
indebouncedFunction()
.Attach the
debouncedFunction()
to the event listener with resize event.Understand properly that it's not calling the
debounce()
function 5 times, it's calling thedebouncedFn()
which is returned fromdebounce()
and saved asdebouncedFunction()
. Thedebounce()
function is called only once in the beginning.On resizing the browser window,
resize
event will be triggered too many times and calldebouncedFunction()
. Let's assume it invokeddebouncedFunction()
5 times frequently.The first time
debouncedFunction
is called,timerId
isnull
.clearTimeout()
is called withnull
, which does nothing. Then it set a timer to execute thecallbackFn
usingapply()
(someExpensiveFunction
is passed ascallbackFn
)after a delay of 500ms and set the id of setTimeout to timerId variable.We don't call the
callbackFn()
directly and useapply()
because of the context's issue. Imagine if we had some object to reference asthis
inside thecallbackFn()
, then the valuethis
would be the global object inside the function because we are not the ones calling thecallbackFn()
,setTimeout()
is calling the function later at some point in time. In our case, the context is nothing so we passednull
to avoid making this a global object.Second call of
debouncedFunction()
happened immediately before 500ms has passed. Before setting the timer to call thecallbackFn()
, it checked if there's already something in the line to be executed. Since the call is happening before 500 ms has passed, the timer has not finished by executing the callbackFn, it'll find a timer with a timerId and clear the timer withclearTimeout()
. Then sets a new fresh timer to call thecallbackFn()
with fresh delay of 500ms.The same sequence of events happens in the third, fourth and fifth calls of
debouncedFunction()
. But after the fifth call ofdebouncedFunction()
, there are no further calls, the last timer is not cleared and executes itscallbackFn()
which is ultimatelysomeExpensiveFunction()
.In this whole process, we called
debouncedFunction()
5 times but calledsomeExpensiveFunction()
only once. We saved resources and avoided calling the expensive function too many times.
See the image below for a visual understanding
I hope, I explained debouncing throughly.
Feel free to connect me on Twitter for feedback, ideas, suggestions, etc.
Thank You so much for reading ๐!
Subscribe to my newsletter
Read articles from Shashank directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by