How to debounce API calls in Vue

Debouncing API calls in Vue prevents excessive requests during rapid user input like typing in search boxes. As the creator of CoreUI with over 12 years of Vue.js experience since 2014, I’ve implemented debouncing in countless search interfaces. Debouncing delays function execution until after a specified time has passed since the last invocation. This approach reduces API calls, improves performance, and provides better user experience.

Use debounce function to delay API calls until user stops typing, reducing unnecessary network requests.

Basic debounce implementation:

<template>
  <div>
    <input
      v-model='searchQuery'
      @input='debouncedSearch'
      placeholder='Search...'
    />
    <div v-if='loading'>Searching...</div>
    <ul>
      <li v-for='result in results' :key='result.id'>
        {{ result.name }}
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const searchQuery = ref('')
const results = ref([])
const loading = ref(false)
let debounceTimer = null

const searchAPI = async (query) => {
  loading.value = true
  try {
    const response = await fetch(`/api/search?q=${query}`)
    results.value = await response.json()
  } catch (error) {
    console.error('Search error:', error)
  } finally {
    loading.value = false
  }
}

const debouncedSearch = () => {
  clearTimeout(debounceTimer)
  debounceTimer = setTimeout(() => {
    if (searchQuery.value.length > 2) {
      searchAPI(searchQuery.value)
    }
  }, 500) // Wait 500ms after user stops typing
}
</script>

Reusable debounce composable:

// useDebounce.js
import { ref, watch, onUnmounted } from 'vue'

export function useDebounce(value, delay = 500) {
  const debouncedValue = ref(value.value)
  let timeout = null

  watch(value, (newValue) => {
    clearTimeout(timeout)
    timeout = setTimeout(() => {
      debouncedValue.value = newValue
    }, delay)
  })

  onUnmounted(() => {
    clearTimeout(timeout)
  })

  return debouncedValue
}

// Usage in component
<script setup>
import { ref, watch } from 'vue'
import { useDebounce } from './useDebounce'

const searchQuery = ref('')
const debouncedQuery = useDebounce(searchQuery, 500)
const results = ref([])

watch(debouncedQuery, async (newQuery) => {
  if (newQuery.length > 2) {
    const response = await fetch(`/api/search?q=${newQuery}`)
    results.value = await response.json()
  }
})
</script>

Debounce function composable:

// useDebounceFn.js
import { onUnmounted } from 'vue'

export function useDebounceFn(fn, delay = 500) {
  let timeout = null

  const debouncedFn = (...args) => {
    clearTimeout(timeout)
    timeout = setTimeout(() => {
      fn(...args)
    }, delay)
  }

  const cancel = () => {
    clearTimeout(timeout)
  }

  onUnmounted(() => {
    cancel()
  })

  return { debouncedFn, cancel }
}

// Usage
<script setup>
import { ref } from 'vue'
import { useDebounceFn } from './useDebounceFn'

const searchQuery = ref('')
const results = ref([])

const searchAPI = async (query) => {
  const response = await fetch(`/api/search?q=${query}`)
  results.value = await response.json()
}

const { debouncedFn: debouncedSearch } = useDebounceFn((query) => {
  searchAPI(query)
}, 500)

const handleInput = () => {
  debouncedSearch(searchQuery.value)
}
</script>

Advanced search with debounce:

<template>
  <div>
    <input
      v-model='searchQuery'
      @input='handleSearch'
      placeholder='Search users...'
    />
    <div v-if='isSearching' class='loading'>
      <span>Searching...</span>
    </div>
    <div v-else-if='searchQuery && results.length === 0' class='no-results'>
      No results found
    </div>
    <ul v-else class='results'>
      <li v-for='user in results' :key='user.id'>
        <strong>{{ user.name }}</strong> - {{ user.email }}
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const searchQuery = ref('')
const results = ref([])
const isSearching = ref(false)
let searchTimeout = null
let abortController = null

const fetchUsers = async (query) => {
  // Cancel previous request if still pending
  if (abortController) {
    abortController.abort()
  }

  abortController = new AbortController()
  isSearching.value = true

  try {
    const response = await fetch(`/api/users/search?q=${query}`, {
      signal: abortController.signal
    })
    const data = await response.json()
    results.value = data
  } catch (error) {
    if (error.name !== 'AbortError') {
      console.error('Search error:', error)
      results.value = []
    }
  } finally {
    isSearching.value = false
  }
}

const handleSearch = () => {
  clearTimeout(searchTimeout)

  if (searchQuery.value.length === 0) {
    results.value = []
    return
  }

  if (searchQuery.value.length < 3) {
    return
  }

  searchTimeout = setTimeout(() => {
    fetchUsers(searchQuery.value)
  }, 500)
}
</script>

<style scoped>
.loading {
  padding: 10px;
  color: #666;
}

.no-results {
  padding: 10px;
  color: #999;
}

.results {
  list-style: none;
  padding: 0;
}

.results li {
  padding: 10px;
  border-bottom: 1px solid #eee;
}
</style>

Debounce with lodash:

npm install lodash-es
<script setup>
import { ref } from 'vue'
import { debounce } from 'lodash-es'

const searchQuery = ref('')
const results = ref([])

const searchAPI = async (query) => {
  const response = await fetch(`/api/search?q=${query}`)
  results.value = await response.json()
}

// Create debounced function
const debouncedSearch = debounce((query) => {
  searchAPI(query)
}, 500)

const handleInput = () => {
  debouncedSearch(searchQuery.value)
}
</script>

Debounce with VueUse:

npm install @vueuse/core
<script setup>
import { ref, watch } from 'vue'
import { useDebounceFn, useDebounce } from '@vueuse/core'

// Method 1: Debounce function
const searchQuery = ref('')
const results = ref([])

const searchAPI = async (query) => {
  const response = await fetch(`/api/search?q=${query}`)
  results.value = await response.json()
}

const debouncedSearch = useDebounceFn((query) => {
  searchAPI(query)
}, 500)

watch(searchQuery, (newQuery) => {
  debouncedSearch(newQuery)
})

// Method 2: Debounce value
const searchQuery2 = ref('')
const debouncedQuery = useDebounce(searchQuery2, 500)

watch(debouncedQuery, async (newQuery) => {
  const response = await fetch(`/api/search?q=${newQuery}`)
  results.value = await response.json()
})
</script>

Autocomplete with debounce:

<template>
  <div class='autocomplete'>
    <input
      v-model='query'
      @input='handleInput'
      @focus='showSuggestions = true'
      @blur='hideSuggestions'
      placeholder='Type to search...'
    />
    <ul v-if='showSuggestions && suggestions.length > 0' class='suggestions'>
      <li
        v-for='suggestion in suggestions'
        :key='suggestion.id'
        @mousedown='selectSuggestion(suggestion)'
      >
        {{ suggestion.name }}
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const query = ref('')
const suggestions = ref([])
const showSuggestions = ref(false)
let debounceTimer = null

const fetchSuggestions = async (searchQuery) => {
  const response = await fetch(`/api/autocomplete?q=${searchQuery}`)
  suggestions.value = await response.json()
}

const handleInput = () => {
  clearTimeout(debounceTimer)

  if (query.value.length < 2) {
    suggestions.value = []
    return
  }

  debounceTimer = setTimeout(() => {
    fetchSuggestions(query.value)
  }, 300)
}

const selectSuggestion = (suggestion) => {
  query.value = suggestion.name
  suggestions.value = []
  showSuggestions.value = false
}

const hideSuggestions = () => {
  setTimeout(() => {
    showSuggestions.value = false
  }, 200)
}
</script>

<style scoped>
.autocomplete {
  position: relative;
  width: 300px;
}

input {
  width: 100%;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.suggestions {
  position: absolute;
  width: 100%;
  max-height: 200px;
  overflow-y: auto;
  background: white;
  border: 1px solid #ddd;
  border-top: none;
  list-style: none;
  padding: 0;
  margin: 0;
  z-index: 1000;
}

.suggestions li {
  padding: 10px;
  cursor: pointer;
}

.suggestions li:hover {
  background: #f0f0f0;
}
</style>

Best Practice Note

Use 300-500ms delay for search inputs—shorter feels responsive, longer reduces requests. Cancel previous API requests when a new search starts using AbortController. Clear debounce timer in component cleanup to prevent memory leaks. Require minimum character count (2-3) before triggering search. Show loading indicator during debounced wait period. Use VueUse or lodash for production-ready debounce implementations. This is how we implement debouncing in CoreUI for Vue—providing responsive search interfaces that minimize API calls, reduce server load, and deliver smooth user experiences in enterprise dashboards.


Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


About the Author