---
title: "Debounce and throttle with VueUse · vuejs.school"
description: "Debounce waits for quiet, throttle keeps a steady pace, and VueUse 13 has a composable for each, built into a search box, a scroll handler and a toast."
canonical_url: "https://vuejs.school/community/blog/debounce-and-throttle-with-vueuse"
meta:
  "og:description": "Debounce waits for quiet, throttle keeps a steady pace, and VueUse 13 has a composable for each, built into a search box, a scroll handler and a toast."
  "og:image": "https://vuejs.school/_og/d/a_~RGVib3VuY2UgYW5kIHRocm90dGxlIHdpdGggVnVlVXNlIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Debounce%2Band%2Bthrottle%2Bwith%2BVueUse%2Cdescription_~RGVib3VuY2Ugd2FpdHMgZm9yIHF1aWV0LCB0aHJvdHRsZSBrZWVwcyBhIHN0ZWFkeSBwYWNlLCBhbmQgVnVlVXNlIDEzIGhhcyBhIGNvbXBvc2FibGUgZm9yIGVhY2gsIGJ1aWx0IGludG8gYSBzZWFyY2ggYm94LCBhIHNjcm9sbCBoYW5kbGVyIGFuZCBhIHRvYXN0Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9kZWJvdW5jZS1hbmQtdGhyb3R0bGUtd2l0aC12dWV1c2Ui%2Cs_QqS4fZaE4jAbf6MS.png"
  "og:image:alt": "Debounce and throttle with VueUse · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Debounce and throttle with VueUse · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~RGVib3VuY2UgYW5kIHRocm90dGxlIHdpdGggVnVlVXNlIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Debounce%2Band%2Bthrottle%2Bwith%2BVueUse%2Cdescription_~RGVib3VuY2Ugd2FpdHMgZm9yIHF1aWV0LCB0aHJvdHRsZSBrZWVwcyBhIHN0ZWFkeSBwYWNlLCBhbmQgVnVlVXNlIDEzIGhhcyBhIGNvbXBvc2FibGUgZm9yIGVhY2gsIGJ1aWx0IGludG8gYSBzZWFyY2ggYm94LCBhIHNjcm9sbCBoYW5kbGVyIGFuZCBhIHRvYXN0Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9kZWJvdW5jZS1hbmQtdGhyb3R0bGUtd2l0aC12dWV1c2Ui%2Cs_QqS4fZaE4jAbf6MS.png"
  "twitter:image:alt": "Debounce and throttle with VueUse · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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.vue**Breaks***

```
<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.vue**Works***

```
<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 stuck**3 questions from the Debounce, throttle and timers quiz.

[**Practice now**](https://vuejs.school/learn/vueuse/debounce-throttle-and-timers?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Deep dive · 6 min read**VueUse 15's new composables: useWebMCP, useLiveAnnouncer and useTemporalNow**](https://vuejs.school/community/blog/vueuse-15s-new-composables-usewebmcp-useliveannouncer-usetemporalnow) [Release notes · 6 min read**VueUse 15: the breaking changes in plain words**](https://vuejs.school/community/blog/vueuse-15-the-breaking-changes-in-plain-words)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/debounce-and-throttle-with-vueuse#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-09-09","description":"Debounce waits for quiet, throttle keeps a steady pace, and VueUse 13 has a composable for each, built into a search box, a scroll handler and a toast.","name":"Debounce and throttle with VueUse","url":"https://vuejs.school/community/blog/debounce-and-throttle-with-vueuse","breadcrumb":{"@id":"https://vuejs.school/community/blog/debounce-and-throttle-with-vueuse#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/debounce-and-throttle-with-vueuse"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/debounce-and-throttle-with-vueuse#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-09-09","description":"Debounce waits for quiet, throttle keeps a steady pace, and VueUse 13 has a composable for each, built into a search box, a scroll handler and a toast.","headline":"Debounce and throttle with VueUse","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~RGVib3VuY2UgYW5kIHRocm90dGxlIHdpdGggVnVlVXNlIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Debounce%2Band%2Bthrottle%2Bwith%2BVueUse%2Cdescription_~RGVib3VuY2Ugd2FpdHMgZm9yIHF1aWV0LCB0aHJvdHRsZSBrZWVwcyBhIHN0ZWFkeSBwYWNlLCBhbmQgVnVlVXNlIDEzIGhhcyBhIGNvbXBvc2FibGUgZm9yIGVhY2gsIGJ1aWx0IGludG8gYSBzZWFyY2ggYm94LCBhIHNjcm9sbCBoYW5kbGVyIGFuZCBhIHRvYXN0Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9kZWJvdW5jZS1hbmQtdGhyb3R0bGUtd2l0aC12dWV1c2Ui%2Cs_QqS4fZaE4jAbf6MS.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/debounce-and-throttle-with-vueuse#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/debounce-and-throttle-with-vueuse#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/debounce-and-throttle-with-vueuse#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Debounce and throttle with VueUse","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~RGVib3VuY2UgYW5kIHRocm90dGxlIHdpdGggVnVlVXNlIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Debounce%2Band%2Bthrottle%2Bwith%2BVueUse%2Cdescription_~RGVib3VuY2Ugd2FpdHMgZm9yIHF1aWV0LCB0aHJvdHRsZSBrZWVwcyBhIHN0ZWFkeSBwYWNlLCBhbmQgVnVlVXNlIDEzIGhhcyBhIGNvbXBvc2FibGUgZm9yIGVhY2gsIGJ1aWx0IGludG8gYSBzZWFyY2ggYm94LCBhIHNjcm9sbCBoYW5kbGVyIGFuZCBhIHRvYXN0Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9kZWJvdW5jZS1hbmQtdGhyb3R0bGUtd2l0aC12dWV1c2Ui%2Cs_QqS4fZaE4jAbf6MS.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~RGVib3VuY2UgYW5kIHRocm90dGxlIHdpdGggVnVlVXNlIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Debounce%2Band%2Bthrottle%2Bwith%2BVueUse%2Cdescription_~RGVib3VuY2Ugd2FpdHMgZm9yIHF1aWV0LCB0aHJvdHRsZSBrZWVwcyBhIHN0ZWFkeSBwYWNlLCBhbmQgVnVlVXNlIDEzIGhhcyBhIGNvbXBvc2FibGUgZm9yIGVhY2gsIGJ1aWx0IGludG8gYSBzZWFyY2ggYm94LCBhIHNjcm9sbCBoYW5kbGVyIGFuZCBhIHRvYXN0Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9kZWJvdW5jZS1hbmQtdGhyb3R0bGUtd2l0aC12dWV1c2Ui%2Cs_QqS4fZaE4jAbf6MS.png"}]}
```