Skip to content
TutorialVueUse

Debounce and throttle with VueUse

Typing fires an event per keystroke and scrolling fires dozens a second. VueUse has a composable for each way of slowing that down.

vuejs.school team9 Sep 20256 min readVueUse 13.9

A search box that calls the API on every keystroke, a scroll handler that runs sixty times a second, a toast that should hide itself after four seconds. Each needs code that runs later, or less often, than the event behind it. VueUse has a composable for each, with one common trap: create the wrapped function in the wrong place and debouncing silently does nothing.

Debounce or throttle: which one you need

Both limit how often a function runs, in different ways:

  • Debounce: wait until the calls stop, then run once. Think of a lift door that resets its timer every time someone steps in, and closes only after nobody has for a moment. Use it when only the final value matters, like a search term.
  • Throttle: run at most once per interval, however many calls arrive. Think of a turnstile that lets one person through, then refuses everyone for a second. Use it when you want regular updates while something is happening, like a scroll position.

Debounce a search box with useDebounceFn

useDebounceFn(fn, ms, options) returns a new function that schedules fn and keeps pushing the schedule back while calls keep coming. The default delay is 200 ms; 300 feels right for typing. The maxWait option caps the wait, so a person who types without pausing still gets results every second instead of never.

Here fakeSearch stands in for your API: it waits 300 ms and filters a list, which is enough to see the behaviour.

src/components/SearchBox.vue
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
import { ref } from 'vue'

interface Result {
  id: number
  title: string
}

const docs = ['Composition API', 'computed()', 'Components', 'Custom directives', 'Composables']

function fakeSearch(q: string): Promise<Result[]> {
  return new Promise((resolve) => {
    setTimeout(() => {
      const hits = docs
        .filter(title => title.toLowerCase().includes(q.toLowerCase()))
        .map((title, id) => ({ id, title }))
      resolve(hits)
    }, 300)
  })
}

const query = ref('')
const results = ref<Result[]>([])
const searching = ref(false)

const search = useDebounceFn(async (q: string) => {
  if (!q.trim()) {
    results.value = []
    return
  }
  searching.value = true
  results.value = await fakeSearch(q)
  searching.value = false
}, 300, { maxWait: 1000 })
</script>

<template>
  <label>
    Search the docs
    <input v-model="query" type="search" @input="search(query)" />
  </label>
  <p v-if="searching">Searching</p>
  <ul v-else>
    <li v-for="result in results" :key="result.id">{{ result.title }}</li>
  </ul>
</template>

@input calls search on every keystroke, and only the last call in a quick burst reaches fakeSearch: add a console.log there, type "comp" quickly, and it logs once, not four times.

The debounced function returns a promise that resolves with whatever fn returned, so await search(query) works if a caller needs the result. A call that is superseded by a newer one resolves with undefined rather than rejecting, unless you pass rejectOnCancel: true.

One thing debouncing does not solve: responses that arrive out of order. A slow response for "co" can arrive after a fast one for "comp" and overwrite it. If your API is that uneven, keep a counter, increment it per call, and ignore any response whose number is not the latest.

Create the debounced function once

The debounced function must be created once, outside the handler. Create it inside and every keystroke gets a fresh function with a fresh timer, so nothing is ever cancelled and every call runs. Here is the bug and its fix in a stripped-down search box:

src/components/SearchBox.vueBreaks
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
import { ref } from 'vue'

const query = ref('')

function runSearch(q: string) {
  console.log('searching for', q)
}

function onInput() {
  // A new debounced function on every keystroke: every call runs
  useDebounceFn(() => runSearch(query.value), 300)()
}
</script>

<template>
  <input v-model="query" @input="onInput" />
</template>
src/components/SearchBox.vueWorks
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
import { ref } from 'vue'

const query = ref('')

function runSearch(q: string) {
  console.log('searching for', q)
}

const onInput = useDebounceFn(() => runSearch(query.value), 300)
</script>

<template>
  <input v-model="query" @input="onInput" />
</template>

The broken version still searches for the right text, which makes the bug easy to miss: typing "comp" quickly logs "comp" four times, because each call reads query.value when its timer fires. The same applies to a debounced function created inside a watch callback or inline in the template.

Debounce a ref with refDebounced

Sometimes you do not want a function; you want a ref that lags behind another. refDebounced(source, ms) returns a read-only ref that takes the source's value once it has been still for the delay. It suits the case where several things depend on the settled value: a results list, a "showing results for" line and a query parameter in the URL.

Here is the search box's script rewritten with it, reusing Result and fakeSearch. In the template, drop @input (v-model="query" is enough) and the searching check with its v-else, since this version has no loading flag:

import { refDebounced } from '@vueuse/core'
import { ref, watch } from 'vue'

const query = ref('')
const settledQuery = refDebounced(query, 300)
const results = ref<Result[]>([])

watch(settledQuery, async (q) => {
  results.value = q.trim() ? await fakeSearch(q) : []
})

query still updates on every keystroke, so the input stays responsive. settledQuery changes 300 ms after the last keystroke, and the watcher runs once per pause. The same function is also exported as useDebounce and debouncedRef, names you will see in older code.

Throttle a scroll handler with useThrottleFn

A scroll handler that runs on every event is wasted work if all it does is toggle a "back to top" button. useThrottleFn(fn, ms, trailing, leading) runs fn at most once per ms. The defaults are leading = true and trailing = false: the first call in a window runs immediately, and calls during the window are dropped. For scroll you want trailing on (the true after 100 below), so the final position after the person stops is not lost.

import { useEventListener, useThrottleFn } from '@vueuse/core'
import { ref } from 'vue'

const showTop = ref(false)

const onScroll = useThrottleFn(() => {
  showTop.value = window.scrollY > 400
}, 100, true)

useEventListener(window, 'scroll', onScroll, { passive: true })

useEventListener adds the listener and removes it when the component unmounts. Ten updates a second is plenty for a button. For anything animated, prefer useRafFn, which runs on the browser's animation frame, over a throttle.

Timers you can stop and restart

useTimeoutFn(fn, ms, options) wraps setTimeout and returns start, stop and isPending. Pass { immediate: false } to create it idle and start it yourself. Here is a toast that hides after four seconds, restarts the clock when a new message replaces the old one, and waits while the pointer is over it:

src/components/Toast.vue
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { ref } from 'vue'

const message = ref('')

const { start, stop } = useTimeoutFn(() => {
  message.value = ''
}, 4000, { immediate: false })

function show(text: string) {
  message.value = text
  start()
}

defineExpose({ show })
</script>

<template>
  <div v-if="message" role="status" @mouseenter="stop()" @mouseleave="start()">
    {{ message }}
  </div>
</template>

Calling start() while a timer is pending clears the old one first, so every new message gets its full four seconds. defineExpose({ show }) lets a parent call show() through a template ref.

useIntervalFn(fn, ms, options) is the setInterval equivalent and returns pause, resume and isActive. A common use is polling, which should stop while the tab is hidden; useDocumentVisibility() says when it is:

import { useDocumentVisibility, useIntervalFn } from '@vueuse/core'
import { ref, watch } from 'vue'

const lastChecked = ref(new Date())

function refresh() {
  lastChecked.value = new Date()
}

const { pause, resume } = useIntervalFn(refresh, 10_000)
const visibility = useDocumentVisibility()

watch(visibility, (state) => {
  if (state === 'visible') resume()
  else pause()
})

refresh stands in for your request. The interval can also be a ref. Change it and the timer picks up the new pace without a restart from you.

What happens when the component unmounts

useTimeoutFn, useIntervalFn and useEventListener all register a cleanup with the component's effect scope, so when the component unmounts the timer is cleared and the listener removed. You never write onUnmounted for them.

useDebounceFn and useThrottleFn are different: they are plain function wrappers with no scope, so a call that is already pending when the component unmounts still runs. That is harmless when the callback only sets a ref, as in the search box.

If the callback touches the DOM or starts a request you would rather not make, set a flag in onUnmounted and have the callback return early when it is set. Checking useMounted() instead does not work: it turns true on mount and stays true after unmount. The other way out is to debounce with useTimeoutFn: each start(q) restarts its timer and passes q to the callback, and unmounting clears the timer.

When debouncing hurts

Do not debounce a button. A debounced submit handler waits 300 ms before it runs, so the person sees nothing happen and clicks again. And it does not stop double submits, the problem it was meant to solve: a click more than 300 ms after the last still sends a second request, even if the first has not finished. Disable the button while the request is in flight instead.

src/components/SaveButton.vue
<script setup lang="ts">
import { ref } from 'vue'

const saving = ref(false)

async function save() {
  if (saving.value) return
  saving.value = true
  try {
    await fetch('/api/save', { method: 'POST' })
  } finally {
    saving.value = false
  }
}
</script>

<template>
  <button :disabled="saving" @click="save">{{ saving ? 'Saving' : 'Save' }}</button>
</template>

The same goes for anything the person expects to react at once: toggles, tabs, keyboard shortcuts, the first keystroke of a filter when the list is local and cheap. Debounce exists to protect something expensive, usually a network request. If the work is cheap, let it run.

Check it stuck3 questions from the Debounce, throttle and timers quiz.
Practice now
Was this helpful?

Keep reading