Introduction
Debouncing is a powerful technique used to optimize event handling in JavaScript. It is used to limit how often a function is executed. It ensures that the function is only triggered after a specified delay and not immediately when an event occurs multiple times in quick succession.
When is Debouncing Useful?
- Reducing network calls while typing in a search box
- Handling window or element resizing events
- Optimizing scroll event listeners
Now that we understand this concept, let’s see it in action! We’ll start with a simple example: incrementing a counter using the debounce method.
let count = 0
const debounce = (fn, delay) => {
let timer;
return function(){
clearTimeout(timer)
timer = setTimeout(()=> {
fn()
console.log('button clicked after', delay)
}, delay)
}
}
let incrementCount = () => {
console.log(count++)
}
incrementCount = debounce(incrementCount, 500)
document.getElementById('btn').addEventListener('click', incrementCount)
In the code above, we start by creating a variable called count
and setting its initial value to 0
. Then, we define an arrow function named debounce
, which takes two parameters: fn
(the function to be executed) and delay (the time to wait before executing fn). Inside debounce, we declare a timer
variable. The key part of this function is that it returns another function, this makes it a higher-order function.
Within the returned function, we first call clearTimeout(timer)
, which cancels any previously scheduled execution of fn
. This ensures that if the function is called repeatedly within the delay period, it won’t execute until the user stops triggering it. Next, we assign a new setTimeout
to timer, where fn()
is executed after the specified delay. We also log a message to the console to indicate when the function actually runs.
You might be wondering how does this function still have access to timer, even though it’s inside another function? That’s because of closures! Closures allow a function to remember variables from its outer scope, even after the outer function has finished executing. Next, we create an incrementCount
function, which simply logs the value of count and increments it. We then reassign incrementCount
to the result of calling debounce()
, passing in incrementCount
itself as the function to debounce, along with a 500ms delay.
Finally, we select the button with the id="btn"
from our HTML and attach a click event listener to it. When the button is clicked, incrementCount is called but because it’s now wrapped inside debounce, it will only execute after 500ms of inactivity, no matter how many times the button is clicked within that time frame.

Now that we’ve covered the basics, let’s dive into a more practical example using debouncing with an API search function.
As I mentioned earlier, debounce is commonly used in live search features. Think about when you search for a product on an e-commerce site like Amazon. Have you noticed how the search bar suggests results as you type? But also, have you observed that it doesn’t fetch results immediately with every keystroke? There’s a slight delay before the suggestions appear. Why is that? Well, every time you press a key, a request is sent to the server this is known as an API call. If this happened on every single keystroke, it would overload the server and slow down the website. That’s where debouncing comes in. It helps delay the API request until the user has stopped typing for a short moment. Haven’t noticed this before? No worries! Let’s implement it ourselves and see debouncing in action.
For this example, we’ll be using HTML, CSS, and JavaScript. I’ve already set up my VS Code editor, so let’s start with the HTML structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auto-Complete Search With Debounce</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Auto-Complete Search With Debounce</h1>
<form>
<input class="input" type="text">
</form>
</div>
<script src="/app.js"></script>
</body>
</html>
In the HTML structure above, we have:
- A title for the page.
- A link to the CSS file to style the page.
- A
<div>
with a class of container, which wraps everything neatly.
- A
<h1>
heading that displays the project title.
- A form element containing an input field for users to type their
search queries.
This setup gives us a simple and clean layout for our search functionality. Now, let’s move on to styling it with CSS
.container{
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin-top: 10rem;
}
.input{
width: 400px;
height: 2.5rem;
border: 2px solid;
border-radius: .5rem;
padding: 0 16px 0 16px;
}
The CSS code above styles our search input and centers it on the page. Here's a breakdown:
- The
.container
class uses Flexbox to align elements properly.
display: flex
enables flexbox layout.
flex-direction column
stacks elements vertically.
justify-content: center;
and align-items: center; ensure everything
is centered on the page.
- The
.input
class styles the search box, giving it a width of 400px,
some padding, a border, and rounded corners for a cleaner look.

Handling the Search Box Visibility
Before diving into the main functionality, let's first ensure that the search result container only appears when we click inside the input field and disappears when we click anywhere outside it. We'll achieve this using the focus
and blur
events.
function autoCompleteSearch() {
let inputBox = document.querySelector(".input");
let searchResultContainer = document.querySelector(".search-result-container");
inputBox.addEventListener("focus", () => {
searchResultContainer.style.display = "block";
});
inputBox.addEventListener("blur", () => {
searchResultContainer.style.display = "none";
});
};
autoCompleteSearch();
How It Works:
- We select the input field and the search result container.
- When the input field is clicked (focused), we set
display: block
to
show the search result container.
- When we click anywhere outside (blur event), the container is hidden
again.
- Remember: The search-result-container should have
display: none
in
the CSS by default.
With this, the first part of our functionality is complete! Now, let’s move to the next step fetching recipe data based on the search input and displaying the results in real time.
function autoCompleteSearch() {
let inputBox = document.querySelector(".input");
let searchResultContainer = document.querySelector(".search-result-container");
inputBox.addEventListener("focus", () => {
searchResultContainer.style.display = "block";
});
inputBox.addEventListener("blur", () => {
searchResultContainer.style.display = "none";
});
// Handle input event
inputBox.addEventListener("input", () => {
const inputValue = inputBox.value;
if (inputValue === "") return;
getRecipeData(inputValue);
});
async function getRecipeData(query) {
try {
const res = await fetch(`https://dummyjson.com/recipes/search?
q=${query}`);
const data = await res.json();
searchResultContainer.innerHTML = "";
data.recipes.forEach((recipe) =>{
const h3 = document.createElement("h3");
h3.textContent = recipe.name;
searchResultContainer.appendChild(h3);
})
} catch (error) {
console.error("Error fetching recipes:", error);
}
}
}
autoCompleteSearch();
Breaking Down the Code
Listening for User Input
Inside the autoCompleteSearch()
function, we:
- Select the input field (.input) and the search result container
(.search-result-container).
- Add an event listener for the focus event on the input field to
display the search result container.
- Add another event listener for the blur event, which hides the search
container when clicking outside.
Fetching Data Based on User Input
inputBox.addEventListener("input", () => {
const inputValue = inputBox.value;
if (inputValue === "") return; // Prevent empty searches
getRecipeData(inputValue);
});
We listen for the input event, which fires every time the user types
something.
The inputValue variable stores the current input value.
If the input field is empty, we return early to prevent unnecessary
API calls.
We then call getRecipeData(inputValue), passing the user’s input as
the search query.
Fetching Recipe Data from an API
async function getRecipeData(query) {
try {
const res = await fetch(`https://dummyjson.com/recipes/search?
q=${query}`);
const data = await res.json();
searchResultContainer.innerHTML = ""; // Clear previous results
data.recipes.forEach((recipe) => {
const h3 = document.createElement("h3");
h3.textContent = recipe.name;
searchResultContainer.appendChild(h3);
});
} catch (error) {
console.error("Error fetching recipes:", error);
}
}
We define an async function getRecipeData(query)
, where query
represents the search term.
Inside a try-catch block, we make an API call using fetch()
.
The response is converted to JSON.
Before displaying new results, we clear any previous search results.
We loop through the retrieved recipes and dynamically create <h3>
elements, appending them to the search container.

The API response contains an object that includes an array of 30
recipe data. To display the recipes on the browser, we loop through the array of objects using a callback function. Within the loop, we create an <h3>
element, set its text content to the recipe name, and append it to the searchResultContainer
. Additionally, the catch
block handles any potential errors in case the API request fails. As soon as I type a key, the search results appear on the browser.

It is working perfectly now but the issue we have anytime we press the any key, it makes an API call, like on keystroke. Let's take a look in the image below.

As we can see, when searching for a mango recipe, an API call is made with every keypress. This is inefficient for performance optimization, imagine thousands of users searching simultaneously, this eventually leads to excessive API requests.
So, how do we solve this problem? This is where the concept of debouncing comes into play. With debouncing, instead of making an API call on every keypress, we introduce a short delay before fetching the data. This ensures that the API request is only triggered after the user has stopped typing for a specified time interval, improving performance and reducing unnecessary network calls.
function autoCompleteSearch() {
let inputBox = document.querySelector(".input");
let searchResultContainer = document.querySelector(".search-result-container");
let timer;
inputBox.addEventListener("focus", () => {
searchResultContainer.style.display = "block";
});
inputBox.addEventListener("blur", () => {
searchResultContainer.style.display = "none";
});
// Handle input event
inputBox.addEventListener("input", () => {
const inputValue = inputBox.value;
if (inputValue === "") return;
clearTimeout(timer)
timer = setTimeout(() => {
getRecipeData(inputValue);
}, 300);
});
async function getRecipeData(query) {
try {
const res = await fetch(`https://dummyjson.com/recipes/search?
q=${query}`);
const data = await res.json();
console.log('API Called', query)
searchResultContainer.innerHTML = "";
data.recipes.forEach((recipe) => {
const h3 = document.createElement("h3");
h3.textContent = recipe.name;
searchResultContainer.appendChild(h3);
});
} catch (error) {
console.error("Error fetching recipes:", error);
}
}
}
autoCompleteSearch();
The above code effectively resolves the issue by introducing debouncing.
Here's how it works:
- A
timer
variable is created to track the timeout.
- Inside the input event listener,
clearTimeout(timer)
ensures that any
previously set timeout is cleared before setting a new one.
- The
setTimeout()
function delays the API call by 300 milliseconds.
- If the user continues typing quickly, the timer resets, preventing
unnecessary API requests.
- As a result, the API call is only made after the user has stopped
typing for 300ms, improving performance and reducing excessive
network requests.
This approach ensures that even slow typists won’t trigger multiple API calls unnecessarily, making the search experience more efficient and optimized.

In the image above, I tested my typing speed and managed to search for a mango recipe before the specified time, resulting in a single API call. However, for slower typists, the issue we're trying to fix would still occur, so increasing the delay is necessary. For an average typist, even if they don't beat the set time, it will still reduce the number of API calls. However, there's another issue, we're triggering an API call again when deleting words from the input field, which shouldn't happen. So, how can we resolve this? The solution is caching.
What is Caching?
Caching is the process of storing frequently accessed data in memory to enhance performance and minimize redundant network requests. Let's implement this functionality to optimize our search further.
function autoCompleteSearch() {
let inputBox = document.querySelector(".input");
let searchResultContainer = document.querySelector(".search-result-
container");
let timer;
const cache = {};
inputBox.addEventListener("focus", () => {
searchResultContainer.style.display = "block";
});
inputBox.addEventListener("blur", () => {
searchResultContainer.style.display = "none";
});
// Handle input event
inputBox.addEventListener("input", () => {
const inputValue = inputBox.value;
if (inputValue === "") return;
if (cache[inputValue]) {
console.log("Fetching from cache:", cache[inputValue]);
displayResults(cache[inputValue]);
return;
}
clearTimeout(timer)
timer = setTimeout(async () => {
const storeData = await getRecipeData(inputValue);
cache[inputValue] = storeData;
}, 300);
});
async function getRecipeData(query) {
try {
const res = await fetch(https://dummyjson.com/recipes/search?
q=${query});
const data = await res.json();
console.log('API Called', query)
cache[query] = data; // Store data in cache
displayResults(data); // Display results
return data;
} catch (error) {
console.error("Error fetching recipes:", error);
}
}
function displayResults(data){
searchResultContainer.innerHTML = "";
if (!data || !data.recipes || data.recipes.length === 0) {
searchResultContainer.innerHTML = "<p>No results found.</p>";
return;
}
data.recipes.forEach((recipe) => {
const h3 = document.createElement("h3");
h3.textContent = recipe.name;
searchResultContainer.appendChild(h3);
});
}
}
autoCompleteSearch();
In this implementation, we introduce a caching mechanism to optimize search functionality. We define a cache object to store previously fetched search results. The term cache is not a reserved keyword, you can name it anything that makes sense to you.
When a user types in the input field, we first check if the entered query already exists in the cache. If it does, we retrieve and display the stored results immediately, avoiding an unnecessary API call. However, if the query is new, we make an API request, store the fetched data in the cache, and then display the results.
Let's test the implementation to confirm that it correctly prevents redundant API calls and improves performance.

I refreshed the page and cleared the console to start fresh. Then, I searched for "mango lassi" recipe, which resulted in two API calls within the set time framenot. Now, when I start deleting the text, the results are fetched from the cache instead of making a new request. If I decide to search for "mango lassi" again, it will also be retrieved from the cache. Let's take a look at this in action below.

As you can see, my search query didn’t trigger a network request to the server, it was fetched directly from the cache. This is a simple in-memory cache, where data is stored in a JavaScript object. If you want the cached data to persist after a page refresh, you’d need to use localStorage or another storage method. However, keep in mind that you might still see an API call while retrieving data from the cache. This happens if you type too slowly and don’t complete your query within the specified time frame.
Conclusion
Implementing caching in search functionality significantly improves performance by reducing redundant API calls and ensuring a smoother user experience. By storing previously fetched results in memory, we minimize unnecessary network requests, making searches faster and more efficient. For even greater persistence, localStorage or other storage solutions can be used. By implementing an optimal debounce delay, we can ensure efficient resource utilization while maintaining a responsive user experience in various applications.