Debounce delays a call until events stop for a given pause — that is search-as-you-type and recalculating after a resize. Throttle lets a call through at most once per interval while events keep coming — that is scroll handling. Below are typed implementations with cancel, flush and leading/trailing options, a useDebouncedValue hook for React, and Vitest tests on fake timers.
After this you will be able to
- choose debounce or throttle for the job: search-as-you-type, scrolling, resizing
- add typed
debounceandthrottlewithleading/trailingoptions andcancelandflushmethods - use
useDebouncedValueand debounce inside an effect in React without leaking timers - test time-based behaviour with Vitest fake timers
Know beforehand
Verified with
TypeScript 6.0.3, 7.0.2Vitest 5.0.3jsdom 30.1.2React 19.3.0@testing-library/react 16.3.3ESLint 10.12.0, eslint-plugin-react-hooks 7.1.1Node.js 24 LTS (24.18.0)
Checked against docs: October 12, 2026
Contents
The problem: calls fire too often
The input event fires on every keystroke, scroll fires as the page scrolls, and resize fires while the user drags the window edge. Send a request or recompute the layout on every one of them and the server gets needless requests while the interface starts to stutter. MDN describes two ways to cut the number of calls — debounce and throttle; the difference is what happens while events keep coming.
Debounce — wait for a pause
- Every new event pushes the call back
- While events keep coming, there are no calls at all
- Usually fires at the end of a burst (trailing), sometimes at the start too (leading)
- Search as you type, draft autosave, recalculation after resize
Throttle — at most once per interval
- Calls happen during the burst too, but at a capped rate
- The first call runs at once (leading), the last one is not lost (trailing)
- Fits when you need to keep up with a constantly changing state
- Scroll position, sending coordinates, upload progress
debounce(fn, 100)
- Fired at, ms
- 340
- With which event
- the last one, #24
debounce(fn, 100, { leading: true })
- Fired at, ms
- 0 and 340
- With which event
- the first and the last
throttle(fn, 100)
- Fired at, ms
- 0, 100, 200, 300
- With which event
- #0, 9, 19, 24
Implementation: debounce.ts and throttle.ts
Both functions take the original function and an interval and return a wrapper with the same arguments. The A extends unknown[] type keeps the signature: debounce((query: string) => …) will not let you call the wrapper with a number. No dependencies; the code runs both in the browser and in Node.js.
export type DebounceOptions = {
leading?: boolean // call immediately on the first call of a burst
trailing?: boolean // call after the pause with the latest arguments
}
export type Debounced<A extends unknown[]> = ((...args: A) => void) & {
cancel: () => void // cancel the pending call
flush: () => void // run the pending call right now
pending: () => boolean
}
export function debounce<A extends unknown[]>(
fn: (...args: A) => void,
waitMs: number,
{ leading = false, trailing = true }: DebounceOptions = {},
): Debounced<A> {
let timer: ReturnType<typeof setTimeout> | undefined
let lastArgs: A | undefined
function invoke() {
const args = lastArgs as A
lastArgs = undefined
fn(...args)
}
function onPause() {
if (trailing && lastArgs) {
invoke()
// with leading: the next call will not run at once until another pause passes
if (leading) {
timer = setTimeout(onPause, waitMs)
return
}
}
timer = undefined
lastArgs = undefined
}
const debounced = (...args: A) => {
const startsSeries = timer === undefined
lastArgs = args
clearTimeout(timer)
timer = setTimeout(onPause, waitMs) // every call pushes the run further back
if (leading && startsSeries) invoke()
}
debounced.cancel = () => {
clearTimeout(timer)
timer = undefined
lastArgs = undefined
}
debounced.flush = () => {
if (timer === undefined) return
clearTimeout(timer)
timer = undefined
if (lastArgs) invoke()
}
debounced.pending = () => timer !== undefined
return debounced
}export type ThrottleOptions = {
leading?: boolean // call immediately on the first call
trailing?: boolean // call at the end of the window with the latest arguments
}
export type Throttled<A extends unknown[]> = ((...args: A) => void) & {
cancel: () => void
flush: () => void
pending: () => boolean
}
export function throttle<A extends unknown[]>(
fn: (...args: A) => void,
waitMs: number,
{ leading = true, trailing = true }: ThrottleOptions = {},
): Throttled<A> {
let timer: ReturnType<typeof setTimeout> | undefined
let lastArgs: A | undefined
function invoke() {
const args = lastArgs as A
lastArgs = undefined
fn(...args)
}
function startWindow() {
timer = setTimeout(() => {
if (trailing && lastArgs) {
invoke()
startWindow() // a call at the end of a window opens a new window
} else {
timer = undefined
lastArgs = undefined
}
}, waitMs)
}
const throttled = (...args: A) => {
lastArgs = args
if (timer !== undefined) return // window is open: remember the arguments and wait
if (leading) invoke()
startWindow()
}
throttled.cancel = () => {
clearTimeout(timer)
timer = undefined
lastArgs = undefined
}
throttled.flush = () => {
if (timer === undefined) return
clearTimeout(timer)
timer = undefined
if (lastArgs) invoke()
}
throttled.pending = () => timer !== undefined
return throttled
}leading
- debounce
- defaults to
false: the first call of a burst runs at once - throttle
- defaults to
true: the first call runs at once
trailing
- debounce
- defaults to
true: a call after the pause - throttle
- defaults to
true: a call at the end of the window
cancel()
- debounce
- drop the pending call and clear the timer
- throttle
- the same
flush()
- debounce
- run the pending call right now
- throttle
- the same, and the window closes
pending()
- debounce
truewhile waiting for the pause- throttle
truewhile a window is open
How to wire it up: search and scrolling
A service catalogue: the search field sends a request 300 ms after the last keystroke, Enter searches immediately, and the scroll position is saved to sessionStorage so the visitor comes back to the same spot. The markup is a #search-form form with a q field and a #results list.
import { debounce } from './debounce'
import { throttle } from './throttle'
const form = document.querySelector<HTMLFormElement>('#search-form')!
const input = form.querySelector<HTMLInputElement>('input[name="q"]')!
const list = document.querySelector<HTMLUListElement>('#results')!
let controller: AbortController | undefined
function item(text: string) {
const li = document.createElement('li')
li.textContent = text
return li
}
async function search(query: string) {
controller?.abort() // the answer to the old request is no longer needed
if (query === '') {
list.replaceChildren()
return
}
const current = new AbortController()
controller = current
try {
const response = await fetch(`/api/services?q=${encodeURIComponent(query)}`, { signal: current.signal })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const names: string[] = await response.json()
list.replaceChildren(...names.map(item))
} catch {
if (current.signal.aborted) return // cancelled by a fresher request — that is fine
list.replaceChildren(item('Could not load the list'))
}
}
// Search: the request goes out 300 ms after the last keystroke
const debouncedSearch = debounce((query: string) => void search(query), 300)
input.addEventListener('input', () => debouncedSearch(input.value.trim()))
form.addEventListener('submit', (event) => {
event.preventDefault()
debouncedSearch.flush() // Enter — search now, without waiting for the pause
})
// Catalogue scroll position: save at most every 250 ms
const saveScroll = throttle(() => {
sessionStorage.setItem('catalog-scroll', String(window.scrollY))
}, 250)
window.addEventListener('scroll', saveScroll)
window.addEventListener('pagehide', () => saveScroll.flush()) // when leaving — write the last valueflush()on Enter — the user explicitly asked to search, so there is no point waiting for the pause.- Debounce does not cancel a request that has already gone out: a slow answer for “ma” can arrive after the answer for “manicure”. So the previous request is aborted with
AbortController— more on that in the asynchronous JavaScript lesson. - The
passiveoption is not needed forscroll: per MDN this event cannot be cancelled, so the handler does not hold up scrolling anyway. It matters forwheelandtouchmove. - On
pagehidewe callflush(): otherwise the last position waiting for the end of the window would be lost when the user leaves the page. - In background tabs browsers enforce a minimum timer delay (per MDN, at least one second in Firefox Desktop), so intervals stretch out there.
In React: useDebouncedValue and cleanup
React needs two guarantees: a stable function reference, or the timer gets lost between renders, and a timer that is cleared on unmount. useDebouncedValue delays the value itself, while a debounced event subscription lives inside an effect.
import { useEffect, useState } from 'react'
import { debounce } from './debounce'
// A value that updates only after changes pause
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs)
return () => clearTimeout(id) // a new value cancels the previous timer
}, [value, delayMs])
return debounced
}
// Window width, recomputed once the user has finished resizing
export function useWindowWidth(delayMs = 200): number {
const [width, setWidth] = useState(() => window.innerWidth)
useEffect(() => {
// one debounced function for the lifetime of the subscription
const onResize = debounce(() => setWidth(window.innerWidth), delayMs)
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
onResize.cancel() // the pending call must not run after unmount
}
}, [delayMs])
return width
}import { useEffect, useState } from 'react'
import { useDebouncedValue } from './useDebounced'
type Props = {
searchServices: (query: string, signal: AbortSignal) => Promise<string[]>
}
export function ServiceSearch({ searchServices }: Props) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<string[]>([])
const debouncedQuery = useDebouncedValue(query.trim(), 300)
useEffect(() => {
if (debouncedQuery === '') return
const controller = new AbortController()
searchServices(debouncedQuery, controller.signal)
.then(setResults)
.catch((error: unknown) => {
if (!controller.signal.aborted) console.error(error)
})
return () => controller.abort() // the stale request is aborted
}, [debouncedQuery, searchServices])
return (
<div role="search">
<label htmlFor="service-query">Search services</label>
<input
id="service-query"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<ul>
{(debouncedQuery === '' ? [] : results).map((name) => (
<li key={name}>{name}</li>
))}
</ul>
</div>
)
}import { useState } from 'react'
import { debounce } from '../debounce'
// Mistake: debounce is recreated on every render
export function BrokenSearch({ onSearch }: { onSearch: (query: string) => void }) {
const [query, setQuery] = useState('')
const debouncedSearch = debounce(onSearch, 300)
return (
<input
aria-label="Search"
value={query}
onChange={(event) => {
setQuery(event.target.value)
debouncedSearch(event.target.value)
}}
/>
)
}If rendering itself is slow rather than the network, try useDeferredValue first: react.dev explains that it needs no fixed delay, adapts to the device, and the deferred render is interrupted by new input. Debounce and throttle remain useful for work outside rendering — for example, to send fewer requests.
If search results are announced to screen readers through a live region (the “Accessibility in code” lesson), the delayed update also spares the user an announcement on every keystroke.
How to check it: fake-timer tests
There is no need to wait a real 300 ms in tests: vi.useFakeTimers() replaces setTimeout, clearTimeout and Date, and vi.advanceTimersByTime(ms) moves time forward. vi.getTimerCount() shows how many timers are left — this is how you check that cancel() and the effect cleanup did not miss anything.
mkdir rate-limit && cd rate-limit
npm init -y
npm pkg set type=module
npm install -D typescript vitest jsdom react react-dom @types/react @types/react-dom @testing-library/react @testing-library/dom{
"compilerOptions": {
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { debounce } from './debounce'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
test('trailing: one call after a pause following the last one, with the latest arguments', () => {
const fn = vi.fn()
const search = debounce(fn, 300)
search('m')
vi.advanceTimersByTime(200)
search('ma') // the timer starts over
vi.advanceTimersByTime(200)
search('manicure')
vi.advanceTimersByTime(299)
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(fn).toHaveBeenCalledTimes(1)
expect(fn).toHaveBeenCalledWith('manicure')
})
test('leading: the first call runs at once, a single call is not repeated', () => {
const fn = vi.fn()
const save = debounce(fn, 300, { leading: true, trailing: false })
save(1)
save(2)
save(3)
expect(fn.mock.calls).toEqual([[1]])
vi.advanceTimersByTime(300)
save(4) // the pause has passed — a new burst
expect(fn.mock.calls).toEqual([[1], [4]])
})
test('leading + trailing: both edges, no back-to-back double call', () => {
const fn = vi.fn()
const d = debounce(fn, 300, { leading: true, trailing: true })
d('a')
expect(fn.mock.calls).toEqual([['a']])
vi.advanceTimersByTime(300)
expect(fn).toHaveBeenCalledTimes(1) // a single call is not repeated at the end
d('b')
d('c')
vi.advanceTimersByTime(300)
expect(fn.mock.calls).toEqual([['a'], ['b'], ['c']])
d('d') // right after trailing — waits for a new pause instead of running instantly
expect(fn).toHaveBeenCalledTimes(3)
vi.advanceTimersByTime(300)
expect(fn.mock.calls.at(-1)).toEqual(['d'])
})
test('cancel drops the pending call and clears the timer', () => {
const fn = vi.fn()
const d = debounce(fn, 300)
d('x')
expect(d.pending()).toBe(true)
d.cancel()
expect(d.pending()).toBe(false)
expect(vi.getTimerCount()).toBe(0)
vi.advanceTimersByTime(1000)
expect(fn).not.toHaveBeenCalled()
})
test('flush runs the pending call now and only once', () => {
const fn = vi.fn()
const d = debounce(fn, 300)
d('x')
d.flush()
expect(fn.mock.calls).toEqual([['x']])
vi.advanceTimersByTime(1000)
expect(fn).toHaveBeenCalledTimes(1)
d.flush() // nothing to run
expect(fn).toHaveBeenCalledTimes(1)
})import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { throttle } from './throttle'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
// 25 events every 10 ms — like a scroll stream over a quarter of a second
function stream(handler: (i: number) => void) {
for (let i = 0; i < 25; i++) {
handler(i)
vi.advanceTimersByTime(10)
}
}
test('default: at once, then at most every 100 ms, the last call is not lost', () => {
const calls: [number, number][] = []
const onScroll = throttle((i: number) => calls.push([Date.now(), i]), 100)
const start = Date.now()
stream(onScroll)
vi.advanceTimersByTime(500)
expect(calls.map(([, i]) => i)).toEqual([0, 9, 19, 24])
expect(calls.map(([t]) => t - start)).toEqual([0, 100, 200, 300])
})
test('trailing: false — only calls at the start of windows', () => {
const fn = vi.fn()
stream(throttle(fn, 100, { trailing: false }))
vi.advanceTimersByTime(500)
expect(fn.mock.calls).toEqual([[0], [10], [20]])
})
test('leading: false — the first call at the end of the window, with the latest arguments', () => {
const fn = vi.fn()
const t = throttle(fn, 100, { leading: false })
t(1)
t(2)
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(100)
expect(fn.mock.calls).toEqual([[2]])
})
test('cancel and flush', () => {
const fn = vi.fn()
const t = throttle(fn, 100)
t(1) // immediately
t(2) // waits for the end of the window
t.cancel()
vi.advanceTimersByTime(500)
expect(fn.mock.calls).toEqual([[1]])
expect(vi.getTimerCount()).toBe(0)
t(3)
t(4)
t.flush() // run the pending call immediately
expect(fn.mock.calls).toEqual([[1], [3], [4]])
expect(t.pending()).toBe(false)
})// @vitest-environment jsdom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { useDebouncedValue, useWindowWidth } from './useDebounced'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
test('useDebouncedValue returns the value after a pause following the last change', () => {
const { result, rerender } = renderHook(({ value }) => useDebouncedValue(value, 300), {
initialProps: { value: '' },
})
rerender({ value: 'm' })
act(() => vi.advanceTimersByTime(200))
rerender({ value: 'ma' })
act(() => vi.advanceTimersByTime(299))
expect(result.current).toBe('')
act(() => vi.advanceTimersByTime(1))
expect(result.current).toBe('ma')
})
test('useDebouncedValue: unmounting clears the timer', () => {
const { rerender, unmount } = renderHook(({ value }) => useDebouncedValue(value, 300), {
initialProps: { value: 'a' },
})
rerender({ value: 'b' })
expect(vi.getTimerCount()).toBe(1)
unmount()
expect(vi.getTimerCount()).toBe(0)
})
function resizeTo(width: number) {
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true })
window.dispatchEvent(new Event('resize'))
}
test('useWindowWidth: one update after a burst of resizes, cancelled on unmount', () => {
const { result, unmount } = renderHook(() => useWindowWidth(200))
const initial = result.current
for (const width of [900, 800, 700]) {
resizeTo(width)
act(() => vi.advanceTimersByTime(50))
}
expect(result.current).toBe(initial)
act(() => vi.advanceTimersByTime(150))
expect(result.current).toBe(700)
resizeTo(600)
unmount()
expect(vi.getTimerCount()).toBe(0)
})$ npx tsc --noEmit -p .
$ npx vitest run
Test Files 5 passed (5)
Tests 18 passed (18)To make sure the tests catch bugs, we broke the code one change at a time: removed clearTimeout in debounce, the pause after a trailing call, the new window in throttle, cancel() in the hook, the cleanup in useDebouncedValue, flush() on Enter and abort() of the old request. Each time at least one test failed.
When requestAnimationFrame is enough, and other limits
- For
scroll, arequestAnimationFramewrapper will not lower the rate: MDN states plainly that frame callbacks fire at the same rate as scroll handlers and recommends measuring the interval withsetTimeout, as our throttle does. - If you need to react to crossing a threshold — show a “back to top” button, load the next batch — MDN suggests
IntersectionObserver: no throttle and no scroll handler needed. - The wrappers do not preserve
thisand discard the return value: a debounced function’s result appears later, so there is nothing to return. For class methods pass an arrow function. - The delay is a trade-off: 300 ms for search and 100–250 ms for scrolling are our choices for the examples, not a standard. Tune them to how users feel and to server load.
- Verification ran in Node.js and jsdom on fake timers; we did not run the recipe code in a real browser.
The foundations of this recipe: the input and scroll events and addEventListener are in “JavaScript in the browser: the DOM and events”; effects, cleanup and AbortController in React are in “useEffect: synchronising with the outside world, and when you don’t need it”; live regions and screen reader announcements are in “Accessibility in code: semantics, focus, ARIA”.
How this was verified5 · passedchecked 12 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.