Debounce откладывает вызов, пока события не прекратятся на заданную паузу, — это поиск по вводу и пересчёт после ресайза. Throttle, пока события идут, пропускает вызов не чаще одного раза за интервал, — это работа с прокруткой. Ниже — типизированные реализации с cancel, flush и опциями leading/trailing, хук useDebouncedValue для React и тесты на поддельных таймерах Vitest.
После материала вы сможете
- выбрать debounce или throttle под задачу: поиск по вводу, прокрутка, ресайз
- подключить типизированные
debounceиthrottleс опциямиleading/trailingи методамиcancelиflush - применить
useDebouncedValueи debounce внутри эффекта в React без утечки таймеров - проверить поведение во времени тестами Vitest с поддельными таймерами
Проверено на
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)
Сверено с документацией: 12 октября 2026 г.
Содержание
Задача: слишком частые вызовы
Событие input приходит на каждое нажатие клавиши, scroll — по мере прокрутки, resize — пока пользователь тянет край окна. Если на каждое событие отправлять запрос или пересчитывать раскладку, сервер получает лишние запросы, а интерфейс начинает подтормаживать. MDN описывает два способа сократить число вызовов — debounce и throttle; разница в том, что происходит, пока события продолжают идти.
Debounce — дождаться паузы
- Каждое новое событие отодвигает вызов
- Пока события идут, вызовов нет вообще
- Обычно срабатывает в конце серии (trailing), иногда и в начале (leading)
- Поиск по вводу, автосохранение черновика, пересчёт после ресайза
Throttle — не чаще раза за интервал
- Вызовы идут и во время серии, но с ограниченной частотой
- Первый вызов — сразу (leading), последний не теряется (trailing)
- Подходит, когда нужно поспевать за меняющимся состоянием
- Позиция прокрутки, отправка координат, прогресс загрузки
debounce(fn, 100)
- Когда сработала, мс
- 340
- С каким событием
- последним, № 24
debounce(fn, 100, { leading: true })
- Когда сработала, мс
- 0 и 340
- С каким событием
- первым и последним
throttle(fn, 100)
- Когда сработала, мс
- 0, 100, 200, 300
- С каким событием
- № 0, 9, 19, 24
Реализация: debounce.ts и throttle.ts
Обе функции принимают исходную функцию и интервал и возвращают обёртку с теми же аргументами. Тип A extends unknown[] сохраняет сигнатуру: debounce((query: string) => …) не даст вызвать обёртку с числом. Зависимостей нет, код работает и в браузере, и в Node.js.
export type DebounceOptions = {
leading?: boolean // вызвать сразу на первом вызове серии
trailing?: boolean // вызвать после паузы с последними аргументами
}
export type Debounced<A extends unknown[]> = ((...args: A) => void) & {
cancel: () => void // отменить отложенный вызов
flush: () => void // выполнить отложенный вызов прямо сейчас
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()
// с leading: следующий вызов не сработает сразу, пока не пройдёт ещё одна пауза
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) // каждый вызов отодвигает срабатывание
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 // вызвать сразу на первом вызове
trailing?: boolean // вызвать в конце окна с последними аргументами
}
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() // вызов в конце окна открывает новое окно
} else {
timer = undefined
lastArgs = undefined
}
}, waitMs)
}
const throttled = (...args: A) => {
lastArgs = args
if (timer !== undefined) return // окно открыто: запомнили аргументы и ждём
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
- по умолчанию
false: первый вызов серии — сразу - throttle
- по умолчанию
true: первый вызов — сразу
trailing
- debounce
- по умолчанию
true: вызов после паузы - throttle
- по умолчанию
true: вызов в конце окна
cancel()
- debounce
- забыть отложенный вызов и снять таймер
- throttle
- то же
flush()
- debounce
- выполнить отложенный вызов прямо сейчас
- throttle
- то же, окно закрывается
pending()
- debounce
true, пока ждём паузу- throttle
true, пока открыто окно
Как подключить: поиск и прокрутка
Каталог услуг: поле поиска отправляет запрос через 300 мс после последнего нажатия, Enter ищет сразу, а позиция прокрутки сохраняется в sessionStorage, чтобы вернуть посетителя на то же место. Разметка — форма #search-form с полем q и список #results.
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() // ответ на старый запрос больше не нужен
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 // отменён более свежим запросом — это норма
list.replaceChildren(item('Не удалось загрузить список'))
}
}
// Поиск: запрос уходит через 300 мс после последнего нажатия
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 — искать сразу, не дожидаясь паузы
})
// Позиция прокрутки каталога: сохраняем не чаще раза в 250 мс
const saveScroll = throttle(() => {
sessionStorage.setItem('catalog-scroll', String(window.scrollY))
}, 250)
window.addEventListener('scroll', saveScroll)
window.addEventListener('pagehide', () => saveScroll.flush()) // уходя — дописать последнее значениеflush()по Enter — пользователь явно попросил искать, ждать паузу незачем.- Debounce не отменяет уже отправленный запрос: медленный ответ на «ма» может прийти позже ответа на «маникюр». Поэтому предыдущий запрос отменяется через
AbortController— подробнее в уроке об асинхронном JavaScript. - Опция
passiveдляscrollне нужна: по MDN это событие нельзя отменить, поэтому обработчик и так не задерживает прокрутку. Она важна дляwheelиtouchmove. - По
pagehideвызываемflush(): иначе последняя позиция, ожидающая конца окна, потеряется при уходе со страницы. - В фоновых вкладках браузеры ограничивают минимальную задержку таймеров (по MDN, в Firefox для настольных систем — не меньше секунды), так что интервалы там растягиваются.
В React: useDebouncedValue и очистка
В React нужны две гарантии: у функции стабильная ссылка, иначе таймер теряется между рендерами, и таймер снимается при размонтировании. useDebouncedValue откладывает само значение, а debounce для подписки на событие живёт внутри эффекта.
import { useEffect, useState } from 'react'
import { debounce } from './debounce'
// Значение, которое обновляется только после паузы в изменениях
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs)
return () => clearTimeout(id) // новое значение отменяет предыдущий таймер
}, [value, delayMs])
return debounced
}
// Ширина окна, пересчитанная после того, как пользователь закончил менять размер
export function useWindowWidth(delayMs = 200): number {
const [width, setWidth] = useState(() => window.innerWidth)
useEffect(() => {
// одна debounce-функция на всё время подписки
const onResize = debounce(() => setWidth(window.innerWidth), delayMs)
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
onResize.cancel() // отложенный вызов не должен сработать после размонтирования
}
}, [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() // устаревший запрос отменяется
}, [debouncedQuery, searchServices])
return (
<div role="search">
<label htmlFor="service-query">Поиск услуги</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'
// Ошибка: debounce создаётся заново на каждом рендере
export function BrokenSearch({ onSearch }: { onSearch: (query: string) => void }) {
const [query, setQuery] = useState('')
const debouncedSearch = debounce(onSearch, 300)
return (
<input
aria-label="Поиск"
value={query}
onChange={(event) => {
setQuery(event.target.value)
debouncedSearch(event.target.value)
}}
/>
)
}Если тормозит сама отрисовка, а не сеть, сначала попробуйте useDeferredValue: react.dev объясняет, что он не требует фиксированной задержки и подстраивается под устройство, а отложенный рендер прерывается новым вводом. Debounce и throttle остаются полезны для работы вне рендера — например, чтобы отправлять меньше запросов.
Если результаты поиска объявляются скринридеру через live-регион (урок «Доступность в коде»), отложенное обновление заодно избавляет пользователя от объявления на каждое нажатие клавиши.
Как проверить: тесты на поддельных таймерах
Ждать настоящие 300 мс в тестах не нужно: vi.useFakeTimers() подменяет setTimeout, clearTimeout и Date, а vi.advanceTimersByTime(ms) проматывает время вперёд. vi.getTimerCount() показывает, сколько таймеров осталось, — так проверяется, что cancel() и очистка эффекта ничего не забыли.
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: один вызов через паузу после последнего, с последними аргументами', () => {
const fn = vi.fn()
const search = debounce(fn, 300)
search('м')
vi.advanceTimersByTime(200)
search('ма') // таймер начинается заново
vi.advanceTimersByTime(200)
search('маникюр')
vi.advanceTimersByTime(299)
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(fn).toHaveBeenCalledTimes(1)
expect(fn).toHaveBeenCalledWith('маникюр')
})
test('leading: первый вызов сразу, одиночный вызов не повторяется', () => {
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) // пауза прошла — новая серия
expect(fn.mock.calls).toEqual([[1], [4]])
})
test('leading + trailing: края серии, без двойного вызова подряд', () => {
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) // одиночный вызов не дублируется в конце
d('b')
d('c')
vi.advanceTimersByTime(300)
expect(fn.mock.calls).toEqual([['a'], ['b'], ['c']])
d('d') // сразу после trailing — ждёт новую паузу, а не срабатывает мгновенно
expect(fn).toHaveBeenCalledTimes(3)
vi.advanceTimersByTime(300)
expect(fn.mock.calls.at(-1)).toEqual(['d'])
})
test('cancel отменяет отложенный вызов и снимает таймер', () => {
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 выполняет отложенный вызов сразу и только один раз', () => {
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() // нечего выполнять
expect(fn).toHaveBeenCalledTimes(1)
})import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { throttle } from './throttle'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
// 25 событий раз в 10 мс — как поток scroll в течение четверти секунды
function stream(handler: (i: number) => void) {
for (let i = 0; i < 25; i++) {
handler(i)
vi.advanceTimersByTime(10)
}
}
test('по умолчанию: сразу, затем не чаще раза в 100 мс, последний вызов не теряется', () => {
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 — только вызовы в начале окон', () => {
const fn = vi.fn()
stream(throttle(fn, 100, { trailing: false }))
vi.advanceTimersByTime(500)
expect(fn.mock.calls).toEqual([[0], [10], [20]])
})
test('leading: false — первый вызов в конце окна, с последними аргументами', () => {
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 и flush', () => {
const fn = vi.fn()
const t = throttle(fn, 100)
t(1) // сразу
t(2) // ждёт конца окна
t.cancel()
vi.advanceTimersByTime(500)
expect(fn.mock.calls).toEqual([[1]])
expect(vi.getTimerCount()).toBe(0)
t(3)
t(4)
t.flush() // выполнить отложенный вызов немедленно
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 отдаёт значение через паузу после последнего изменения', () => {
const { result, rerender } = renderHook(({ value }) => useDebouncedValue(value, 300), {
initialProps: { value: '' },
})
rerender({ value: 'м' })
act(() => vi.advanceTimersByTime(200))
rerender({ value: 'ма' })
act(() => vi.advanceTimersByTime(299))
expect(result.current).toBe('')
act(() => vi.advanceTimersByTime(1))
expect(result.current).toBe('ма')
})
test('useDebouncedValue: размонтирование снимает таймер', () => {
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: один пересчёт после серии resize, отмена при размонтировании', () => {
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)Чтобы убедиться, что тесты ловят ошибки, мы по очереди ломали код: убирали clearTimeout в debounce, паузу после trailing-вызова, новое окно в throttle, cancel() в хуке, очистку в useDebouncedValue, flush() по Enter и abort() старого запроса. Каждый раз падал хотя бы один тест.
Когда хватит requestAnimationFrame и другие ограничения
- Для
scrollобёртка наrequestAnimationFrameчастоту не снизит: MDN прямо пишет, что колбэки кадра вызываются с той же частотой, что и обработчики прокрутки, и советует отмерять интервал черезsetTimeout, как в нашем throttle. - Если нужно реагировать на пересечение порога — показать кнопку «наверх», подгрузить следующую порцию, — MDN советует
IntersectionObserver: там не нужен ни throttle, ни обработчик прокрутки. - Обёртки не сохраняют
thisи отбрасывают возвращаемое значение: результат debounced-функции появляется позже, поэтому возвращать его некуда. Для методов класса передавайте стрелочную функцию. - Задержка — компромисс: 300 мс для поиска и 100–250 мс для прокрутки — наш выбор для примеров, а не стандарт. Подбирайте её по ощущениям пользователей и нагрузке на сервер.
- Проверка шла в Node.js и jsdom на поддельных таймерах; в настоящем браузере код рецепта мы не запускали.
Основы рецепта: события input и scroll, addEventListener — в уроке «JavaScript в браузере: DOM и события»; эффекты, очистка и AbortController в React — в «useEffect: синхронизация с внешним миром и когда он не нужен»; live-регионы и объявления для скринридера — в «Доступность в коде: семантика, фокус, ARIA».
Как проверено5 · пройденосверено 12 октября 2026 г.
- запущено, работает
src/debounce.ts, src/throttle.ts
tsc --noEmit (strict) на TypeScript 6.0.3 и 7.0.2 — 0 ошибок; vitest 5.0.3 в Node.js 24.18.0 с поддельными таймерами: debounce 5 тестов, throttle 4 теста; таблица «25 событий через 10 мс» получена пробным тестом (debounce: 340 мс/№24; leading: 0 и 340; throttle: 0/100/200/300, №0/9/19/24)
- запущено, работает
src/catalog.ts
vitest в jsdom 30.1.2 с подменой fetch: один запрос после паузы, flush по Enter, сообщение при HTTP 500, медленный старый ответ не перезаписывает свежий (AbortController), sessionStorage не чаще раза в 250 мс + запись по pagehide
- запущено, работает
src/useDebounced.ts, src/ServiceSearch.tsx, src/bad/BrokenSearch.tsx
vitest + @testing-library/react 16.3.3, React 19.3.0, jsdom: useDebouncedValue (пауза, снятие таймера при размонтировании), useWindowWidth (один пересчёт, cancel при размонтировании), ServiceSearch — один запрос на 4 нажатия; BrokenSearch — 4 вызова вместо одного. ESLint 10.12.0 + eslint-plugin-react-hooks 7.1.1 (flat recommended) — 0 замечаний, включая BrokenSearch. Ранний вариант useDebouncedCallback с useRef+useMemo отклонён: правило react-hooks/refs
- запущено, работает
Мутационная проверка тестов
по одной поломке: без clearTimeout в debounce, без паузы после trailing при leading, leading на каждом вызове, без нового окна в throttle, без проверки окна, без cancel в useWindowWidth, без очистки в useDebouncedValue, без flush по Enter, без abort старого запроса — каждый раз падает ≥1 тест
- запущено, работает
EN-версия кода
копия rate-limit-en с переведёнными строками и комментариями: vitest 18/18, tsc 6.0.3 и 7.0.2, eslint — чисто
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.