Briefly
Working with forms is not always as simple as it may seem. In this article, we will discuss how to create a search field that will suggest query options without overloading your server with a million requests per second.
debounce
is a function that "delays" the call of another function until a certain amount of time has passed since the last call.
Such a function is used to avoid bombarding the server with a bunch of requests on every keystroke, instead waiting for the user to finish or pause their input, and only then sending a request to the server.
This is needed not only in search forms, as we have shown, but also if we are writing:
- an analytics script that counts something after a scroll event, if we don't want to start counting until the user has finished scrolling the page;
- a module that waits for a repeated action to finish before performing its task.
Markup
Let’s start with the markup for the form. We will have the form itself #search
and a list of links for which we will receive data in response. The form has an action
attribute, which will work if users disable scripts. For the input field, we will use <input>
with the type search
so that browsers apply additional magic with autofill and appropriate buttons on mobile keyboards. It is not necessary to specify a type for buttons, as submit
is the default type.
<form action="/some-route" method="GET" id="search-form"> <label>Find your favorite pizza:</label> <input type="search" name="query" placeholder="Margherita"> <button>Search!</button></form><ul class="search-results"></ul>
<form action="/some-route" method="GET" id="search-form"> <label>Find your favorite pizza:</label> <input type="search" name="query" placeholder="Margherita"> <button>Search!</button> </form> <ul class="search-results"></ul>
The form will look simple and work in the standard way. We will handle the form with JavaScript. To learn more about how this works, read the articles “Form Validation” and “Working with Forms in JavaScript”.

Just a form 🙂
Fake Server for Requests
The next step is to prepare a "server" to which we will be sending requests from the form.
Since this is just an example, we won't set up a "real server™". Instead, we will write a "stub" for the server that will do everything we need.
We will need the "server" to respond to requests with an array of pizza names, which we will then convert into links and display in the list under the form.
First, we prepare a list of names (so to speak, a database 😃):
// We will search for names in this array,// which contain the user's queryconst pizzaList = [ 'Margherita', 'Pepperoni', 'Hawaiian', '4 Cheeses', 'Diablo', 'Sicilian']
// We will search for names in this array, // which contain the user's query const pizzaList = [ 'Margherita', 'Pepperoni', 'Hawaiian', '4 Cheeses', 'Diablo', 'Sicilian' ]
Next, we will create an object that simulates an asynchronous response (check out the article about asynchronous behavior in JavaScript if this concept is unfamiliar to you).
In the contains
function, we will check if the user's query is contained in any of the names. The server mock object will have a .search
method that returns a promise. Thus, we will emulate "asynchronicity", as if we "went to the server, it thought, and replied". The timeout is needed solely to adjust the delay time. In the response, we will send an object with the filtered array as the value of the list
field.
function contains(query) { return pizzaList.filter((title) => title.toLowerCase().includes(query.toLowerCase()) )}const server = { search(query) { return new Promise((resolve) => { setTimeout( () => resolve({ list: query ? contains(query) : [], }), 150 ) }) },}
function contains(query) { return pizzaList.filter((title) => title.toLowerCase().includes(query.toLowerCase()) ) } const server = { search(query) { return new Promise((resolve) => { setTimeout( () => resolve({ list: query ? contains(query) : [], }), 150 ) }) }, }
We will be able to call this method like this:
(async () => { const response = await server.search('Peppe')})()
(async () => { const response = await server.search('Peppe') })()
Or like this:
server.search('Peppe').then(() => { /* … */})
server.search('Peppe').then(() => { /* … */ })
First Version of the Handler
First, we will write the foundation for handling the form without debounce
, ensure that everything works, see the reason why we need debounce
at all, and then we will write it.
We will obtain references to all the elements we will work with:
const searchForm = document.getElementById('search-form')const searchInput = searchForm.querySelector('[type="search"]')const searchResults = document.querySelector('.search-results')
const searchForm = document.getElementById('search-form') const searchInput = searchForm.querySelector('[type="search"]') const searchResults = document.querySelector('.search-results')
Then we will write an input event handler for the search field:
searchInput.addEventListener('input', (e) => { // Get the value in the field, // on which the event was triggered const { value } = e.target // Get the list of pizza names from the server server.search(value).then(function (response) { const { list } = response // Go through each item in the list // and create a string with several <li> elements… const html = list.reduce((markup, item) => { return `${markup}<li>${item}</li>` }, ``) // …which we then use as the content of the list searchResults.innerHTML = html })})
searchInput.addEventListener('input', (e) => { // Get the value in the field, // on which the event was triggered const { value } = e.target // Get the list of pizza names from the server server.search(value).then(function (response) { const { list } = response // Go through each item in the list // and create a string with several <li> elements… const html = list.reduce((markup, item) => { return `${markup}<li>${item}</li>` }, ``) // …which we then use as the content of the list searchResults.innerHTML = html }) })
Let’s check that when we type a string, for example a
, we see a list on the page.

It works 💥
Now, let’s return to the problem we started with. Right now, every keystroke in the field triggers a request to the server. We can check this by adding a log to the search
method on the server:
const server = { search(query) { // Add a logger that will output // each received request console.log(query) return new Promise((resolve) => { setTimeout( () => resolve({ list: query ? contains(query) : [], }), 100 ) }) },}
const server = { search(query) { // Add a logger that will output // each received request console.log(query) return new Promise((resolve) => { setTimeout( () => resolve({ list: query ? contains(query) : [], }), 100 ) }) }, }
Now, let’s enter a pizza name:

We quickly entered 5 letters, resulting in 5 requests. This is wasteful.
To avoid hitting the server on every input change, we want to "delay" the request until the user pauses their input.
Moreover, if our server were real, we couldn't guarantee that the responses would arrive in the order the requests were sent.
In such a situation, it could happen that a response to an earlier request could arrive later than all others.
Writing debounce()
Alright, we’ve identified the problem. How can we solve it now?
The first idea that comes to mind is to change the event handler to monitor when to send requests and when not to. But that is not a very good idea.
- This mixes responsibilities; handlers are best for processing events, not for doing something else in parallel, or they quickly become unreadable.
- If we have a similar form, we would need to implement the same feature again.
We need to write a function that will know when to invoke another function. Such functions that take other functions as arguments or return a function as a result are called higher-order functions.
So, debounce
is a higher-order function that takes a function to be "delayed" as an argument.
Let's go. The arguments will be the function to "delay" and the time interval after which the function should be called. As a result, we return another function. This is necessary so that we don't have to change other parts of the code. A little later we will see how this helps. In the variable previous
, we store the timestamp of the previous call, and in the variable for the current call, we store the current timestamp. This is necessary to compare when the function was called this time with the previous one. If the difference between calls is less than the specified interval, we clear the timeout that is responsible for calling the argument function. Note that we pass all arguments .
received in the perform
function. This is also necessary so that we don’t have to change other parts of the code. If the timeout was cleared, the call will not occur. If it was not cleared, then callee
will be invoked. Thus, we are "pushing back" the call to callee
until everything "settles down" outside.
function debounce(callee, timeoutMs) { return function perform(...args) { let previousCall = this.lastCall this.lastCall = Date.now() if (previousCall && this.lastCall - previousCall <= timeoutMs) { clearTimeout(this.lastCallTimer) } this.lastCallTimer = setTimeout(() => callee(...args), timeoutMs) }}
function debounce(callee, timeoutMs) { return function perform(...args) { let previousCall = this.lastCall this.lastCall = Date.now() if (previousCall && this.lastCall - previousCall <= timeoutMs) { clearTimeout(this.lastCallTimer) } this.lastCallTimer = setTimeout(() => callee(...args), timeoutMs) } }
We can use such a debounce
like this:
// The function we want to "delay"function doSomething(arg) { // …}doSomething(42)// Now the same function but wrapped in debounce()const debouncedDoSomething = debounce(doSomething, 250)debouncedDoSomething(42)
// The function we want to "delay" function doSomething(arg) { // … } doSomething(42) // Now the same function but wrapped in debounce() const debouncedDoSomething = debounce(doSomething, 250) debouncedDoSomething(42)
debounced
is indeed a function because we return a function from debounce
. debounced
accepts the same arguments as do
because perform
inside debounce
passes all arguments unchanged to do
. So the call to debounced
will be the same as calling do
.
Applying debounce()
Now we can apply debounce
in our handler. First, let’s refactor slightly. We will extract the event handler into a separate function. Inside, it will be the same, but this makes it easier for us to wrap it in debounce
.
function handleInput(e) { const { value } = e.target server.search(value).then(function (response) { const { list } = response const html = list.reduce((markup, item) => { return `${markup}<li>${item}</li>` }, ``) searchResults.innerHTML = html })}searchInput.addEventListener('input', handleInput)
function handleInput(e) { const { value } = e.target server.search(value).then(function (response) { const { list } = response const html = list.reduce((markup, item) => { return `${markup}<li>${item}</li>` }, ``) searchResults.innerHTML = html }) } searchInput.addEventListener('input', handleInput)
Now let's wrap the extracted function and update add
. We will specify that we want to wait 250 ms before executing the handler. Then we pass the new debounced
function to add
.
function handleInput(e) { // …}const debouncedHandle = debounce(handleInput, 250)searchInput.addEventListener('input', debouncedHandle)
function handleInput(e) { // … } const debouncedHandle = debounce(handleInput, 250) searchInput.addEventListener('input', debouncedHandle)
Now, if we quickly type several characters, we will send only one request:

Instead of sending five requests, we now send only one!
Note that the API of the function has not changed. We still pass the event
as we did before. So for the outside world, the debounced function behaves exactly the same as a simple handler function.
This is convenient because only one small part of the program changes, without affecting the systems as a whole.
Result
The full example of the search string will look like this:
In practice
Advice 1
🛠 Use debounce
to optimize operations that can be performed once at the end.
For example, this is suitable for a search form. However, for tracking mouse movement — no, because it would be strange to wait for the user to stop the cursor.
For such tasks that can be performed once in a certain amount of time, throttle
is better suited.