Updated June 2026. Tested on Vue 3 with <script setup>. Part of the Techalyst Vue 3 series.

Debounce and throttle both limit how often something runs in response to rapid events. The neat trick in Vue 3 is that you can bake the timing straight into a ref with customRef, so any component binding it gets debounced behaviour for free, with no per-component plumbing. Let us build it from the ground up.

Debounce, in one sentence

A debounced function waits until the activity stops before running, then runs once. Type ten characters quickly and the handler fires a single time, after you pause. It is the right tool for search-as-you-type, form auto-save, and resize handlers.

The closure foundation

The classic implementation leans on a closure: an outer function that runs once and holds a shared timer id, returning an inner function bound to the event.

function debounce(fn, delay) {
  let timer = null            // shared across every call of the inner fn

  return function (...args) {
    clearTimeout(timer)       // cancel the previous pending run
    timer = setTimeout(() => fn(...args), delay)
  }
}

Every call clears the previous timer and sets a new one, so only the final call in a burst ever fires. The single shared timer is the whole mechanism, and clearTimeout must come before setTimeout.

Why not just debounce a method?

You might try to drop that pattern into a component function, but it breaks: a method runs fresh on every call, so let timer = null resets each time and nothing is ever cancelled. You would have to lift the timer into component state to share it. That works, but it scatters timing logic across every component that needs it. A cleaner home is the ref itself.

customRef: timing inside the ref

customRef lets you define a ref with your own getter and setter. Crucially, the factory function runs once, so a timer declared inside it is genuinely shared across every read and write, exactly like the outer function in the closure version.

import { customRef } from 'vue'

export function debouncedRef(value, delay = 400) {
  let timer = null

  return customRef((track, trigger) => ({
    get() {
      track()             // register this ref as a reactive dependency
      return value
    },
    set(newValue) {
      clearTimeout(timer)
      timer = setTimeout(() => {
        value = newValue
        trigger()         // tell Vue the value changed, re-render
      }, delay)
    },
  }))
}

Two required calls make it reactive: track() in the getter registers anyone reading the ref, and trigger() in the setter notifies them once the debounced value actually updates. The setter stores the new value only after the delay, so reads stay on the old value until the user pauses.

Using it in a search input

Now any component can bind it like a normal ref and get debounced updates for free.

<script setup>
import { watch } from 'vue'
import { debouncedRef } from './debouncedRef'

const search = debouncedRef('', 400)

watch(search, (term) => {
  // fires only after the user stops typing for 400ms
  fetchResults(term)
})
</script>

<template>
  <input v-model="search" placeholder="Search..." />
</template>

The v-model writes to the ref on every keystroke, but the setter swallows the rapid writes and only commits the final value after 400ms of silence, so watch (and your API call) runs once. Pair this with a cancellable request from the watchers post and you have a tidy, race-free search box.

Turning debounce into throttle

Throttle is the sibling: instead of waiting for silence, it allows an update at a fixed interval while the activity continues. The conversion is almost comically small, drop the clearTimeout.

import { customRef } from 'vue'

export function throttledRef(value, delay = 1000) {
  let waiting = false

  return customRef((track, trigger) => ({
    get() {
      track()
      return value
    },
    set(newValue) {
      if (waiting) return         // ignore writes during the cooldown
      waiting = true
      setTimeout(() => {
        value = newValue
        trigger()
        waiting = false           // open the gate again
      }, delay)
    },
  }))
}

Without cancellation, the first write in each window starts a timer that always completes, and a waiting flag ignores the rest until the interval elapses. The result is at most one update per interval no matter how fast the writes arrive, which is what you want for scroll position or rapid click counters.

Debounce Throttle
Uses clearTimeout Yes, cancels the previous timer No
Fires while active Never Once per interval
Fires after stopping Once Pending timer, if any
Best for Search input, auto-save, resize Scroll, click rate-limiting

Wrapping up

Debounce waits for quiet, throttle paces a steady drip, and both are closures at heart. By moving the timer into a customRef factory (which runs once) you get a shared timer with no component-level state, and track/trigger keep the ref reactive. Bind it with v-model and your component never has to know the timing exists. Swap clearTimeout in or out to switch between the two behaviours.

More in the series: watchers in Vue 3 and Vue 3 reactivity with ref and reactive. Questions welcome below.