Debouncing and Throttling
Debouncing waits for a pause before acting, throttling caps how often it acts; both need the pending timer cleared.
watch(source, (v) => { const t = setTimeout(() => act(v), 300); onWatcherCleanup(() => clearTimeout(t)) })An input event fires on every keystroke. A search box wired straight to a request sends six requests for a six-letter word, five of which nobody waits for. Debouncing and throttling are the two ways to reduce that, and they are not interchangeable.
What each one changes
Debounce: wait until the source has been quiet for N milliseconds, then act once. Every new event cancels the pending timer and starts a new one. Typing continuously means nothing happens until you stop. Right for search boxes, autosave and validation.
Throttle: act at most once per N milliseconds while events keep coming. Right for things that must stay live during the activity - scroll position, a resize handler, a pointer-driven preview.
How to build one
Both are a setTimeout and a cleared handle. In Vue the cleanup point is the thing to get right: in a watch, register clearTimeout through onWatcherCleanup (Vue 3.5) or the onCleanup argument passed as the callback's third parameter, which exists in every Vue 3 version. In a composable, use onScopeDispose.
What trips people up
A debounce delay above roughly 400ms feels broken rather than efficient. And a debounce alone does not fix out-of-order responses: it makes a race rarer by sending fewer requests, but two slow requests can still overlap, so keep the abort or the request id. Before writing any timer, check whether v-model.lazy is enough - it updates on change instead of input, so one update per blur.
Example
<script setup lang="ts">
import { ref, watch, onWatcherCleanup } from 'vue'
const query = ref('')
const debounced = ref('')
// Each keystroke re-runs this callback. The re-run first cleans up the previous
// one, which clears its timer, so only a 300ms pause lets the write happen.
watch(query, (value) => {
const timer = setTimeout(() => {
debounced.value = value
}, 300)
onWatcherCleanup(() => clearTimeout(timer))
})
</script>
<template>
<label for="search">Search</label>
<input id="search" v-model="query" type="search" />
<p>Searching for: {{ debounced || 'nothing yet' }}</p>
</template>
<style scoped>
label {
display: block;
}
</style>When to use it
- Send one search request after the user stops typing instead of one per keystroke.
- Autosave a draft a second after the last edit.
- Update a sticky header on scroll without running the handler on every frame.
More examples
A reusable debounced ref
Returning a second ref rather than a callback keeps the debounce out of your request code: bind the input to query, and watch debounced for the fetch. The returned ref is writable in type terms but you should treat it as read-only, since the next source change overwrites it. The same watch runs inside whatever component called the composable, so its cleanup is tied to that component's lifetime automatically.
// src/composables/useDebouncedRef.ts
import { ref, watch, onWatcherCleanup, type Ref } from 'vue'
export function useDebouncedRef<T>(source: Ref<T>, delay = 300): Ref<T> {
const debounced = ref(source.value) as Ref<T>
watch(source, (value) => {
const timer = setTimeout(() => {
debounced.value = value
}, delay)
onWatcherCleanup(() => clearTimeout(timer))
})
return debounced
}
// const query = ref('')
// const debounced = useDebouncedRef(query, 300)
// query.value = 'a'; query.value = 'ab'; query.value = 'abc'
// read debounced.value immediately -> ''
// read debounced.value 300ms after the last write -> 'abc'
// Output: '' then 'abc'A throttled callback with leading and trailing calls
The first call goes through immediately, which is what makes this a throttle rather than a debounce - a scroll handler that waits before its first update looks frozen. The queued trailing call matters too: without it, the final event of a burst is dropped and the handler ends on a stale value. onScopeDispose clears the pending timer when the owning component unmounts.
// src/composables/useThrottledFn.ts
import { onScopeDispose } from 'vue'
export function useThrottledFn<T extends unknown[]>(
fn: (...args: T) => void,
interval: number
): (...args: T) => void {
let last = 0
let timer: ReturnType<typeof setTimeout> | undefined
onScopeDispose(() => clearTimeout(timer))
return (...args: T) => {
const wait = interval - (performance.now() - last)
if (wait <= 0) {
last = performance.now()
fn(...args)
return
}
clearTimeout(timer)
timer = setTimeout(() => {
last = performance.now()
fn(...args)
}, wait)
}
}
// 20 scroll events over 200ms with interval 50:
// fn runs at 0ms, 50ms, 100ms, 150ms, 200ms - five times, not twenty.
// Output: 5v-model.lazy, and the longhand both forms expand to
The pairs here are the same thing written two ways, and seeing the longhand explains the modifier: .lazy is purely a change of event name. For a form field that only needs to be read when the user finishes, this removes the need for any timer at all. For live results as someone types it is the wrong tool, because nothing happens until focus leaves the field - that is the case for a debounce.
<script setup lang="ts">
import { ref } from 'vue'
const query = ref('')
</script>
<template>
<!-- v-model on a text input is shorthand for :value plus @input -->
<input v-model="query" type="search" />
<input
:value="query"
type="search"
@input="query = ($event.target as HTMLInputElement).value"
/>
<!-- .lazy swaps the listener to change: one update per blur or Enter -->
<input v-model.lazy="query" type="search" />
<input
:value="query"
type="search"
@change="query = ($event.target as HTMLInputElement).value"
/>
</template>
Discussion