A girl sits at a table, with a flower and a clock on the table, and in the background, there are electronic clocks and large analog clocks

Debounce with the Example of a Search Form

How not to overload your server with a large flow of requests.

Time to read: 13 min

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-form 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”.

Styled form

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.

Form filled with the letter "a"

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:

Form which sends a request for each of the five entered letters

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 previousCall, 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 ...args 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)

        
        
          
        
      

debouncedDoSomething() is indeed a function because we return a function from debounce(). debouncedDoSomething() accepts the same arguments as doSomething() because perform inside debounce() passes all arguments unchanged to doSomething(). So the call to debouncedDoSomething() will be the same as calling doSomething().

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 addEventListener. We will specify that we want to wait 250 ms before executing the handler. Then we pass the new debounced function to addEventListener.

        
          
          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:

Search form with debounce. Only one request is sent to the server

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:

Open demo in the new window

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.