How to buildOctober 12, 20266 min read
JavaScript from scratchStep 9 of 9

Recipe: debounce and throttle

In short

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.

Code recipeLevel: BeginnerJavaScript UtilitiesPractice: ~20–40 min

After this you will be able to

  • choose debounce or throttle for the job: search-as-you-type, scrolling, resizing
  • add typed debounce and throttle with leading/trailing options and cancel and flush methods
  • use useDebouncedValue and debounce inside an effect in React without leaking timers
  • test time-based behaviour with Vitest fake timers

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
  1. 01The problem: calls fire too often
  2. 02Implementation: debounce.ts and throttle.ts
  3. 03How to wire it up: search and scrolling
  4. 04In React: useDebouncedValue and cleanup
  5. 05How to check it: fake-timer tests
  6. 06When requestAnimationFrame is enough, and other limits

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
The same stream: 25 events every 10 ms (0–240 ms), 100 ms interval. Numbers come from a fake-timer test.

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.

src/debounce.ts
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
}
Debounce: fires after a waitMs pause with the latest arguments.
src/throttle.ts
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
}
Throttle: at most one call per waitMs, the last call of a burst is not lost.

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
true while waiting for the pause
throttle
true while 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.

src/catalog.ts
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 value
Page wiring: debounce for typing, throttle for scrolling.
  • flush() 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 passive option is not needed for scroll: per MDN this event cannot be cancelled, so the handler does not hold up scrolling anyway. It matters for wheel and touchmove.
  • On pagehide we call flush(): 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.

src/useDebounced.ts
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
}
Hooks: a deferred value and the window width after resizing.
src/ServiceSearch.tsx
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>
  )
}
Service search: the request uses the debounced value, the stale one is aborted in the effect cleanup.
src/bad/BrokenSearch.tsx
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)
      }}
    />
  )
}
Do not do this: the wrapper is recreated on every render.

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.

bash
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
A scratch project: TypeScript, Vitest, jsdom for DOM and React tests.
tsconfig.json
{
  "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"]
}
TypeScript strict mode; noEmit means type-checking without a build.
src/debounce.test.ts
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)
})
Debounce: trailing, leading, both edges, cancel, flush.
src/throttle.test.ts
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)
})
Throttle: call rate and timing, without trailing, without leading, cancel and flush.
src/useDebounced.test.tsx
// @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)
})
Hooks: renderHook, act and fake timers; unmounting clears the timer.
terminal
$ npx tsc --noEmit -p .
$ npx vitest run

 Test Files  5 passed (5)
      Tests  18 passed (18)
Output shortened. Five test files: besides the ones shown, catalog.test.ts and ServiceSearch.test.tsx (in jsdom, with a stubbed fetch).

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, a requestAnimationFrame wrapper will not lower the rate: MDN states plainly that frame callbacks fire at the same rate as scroll handlers and recommends measuring the interval with setTimeout, 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 this and 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.