Обработчики событий scroll, resize, input и mousemove срабатывают десятки раз в секунду. Если внутри них дёргать тяжёлую логику — пересчёт layout, запрос к API, фильтрацию большого списка — браузер начинает захлёбываться, интерфейс лагает. Debounce и throttle — два приёма, которые ограничивают частоту вызовов функции. В этой статье разберём, чем они отличаются, напишем обе реализации с нуля и посмотрим, где какую применять.

В чём разница между debounce и throttle
Оба приёма решают одну задачу — уменьшить число вызовов функции, но логика у них разная:
- Debounce откладывает вызов до тех пор, пока события не прекратятся. Каждое новое событие сбрасывает таймер. Функция выполнится один раз — через заданную паузу после последнего события. Классика: поиск по мере ввода (отправляем запрос, когда пользователь перестал печатать).
- Throttle гарантирует, что функция выполняется не чаще одного раза в заданный интервал, сколько бы событий ни прилетело. Классика: обработчик
scrollилиmousemove, где нужны регулярные обновления, но не на каждый пиксель.
Простое правило: нужен финальный результат после серии действий — debounce. Нужны регулярные обновления во время серии — throttle.
Реализация debounce с нуля
Идея: храним идентификатор таймера в замыкании. На каждый вызов сбрасываем предыдущий таймер и заводим новый. Функция сработает только если за delay миллисекунд её больше не дёргали.
// debounce: вызов функции через delay мс после ПОСЛЕДНЕГО обращения
function debounce(fn, delay = 300) {
let timeoutId;
return function (...args) {
// отменяем запланированный вызов, если он ещё не произошёл
clearTimeout(timeoutId);
// планируем новый вызов; this сохраняем через стрелочную функцию
timeoutId = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// Применение: запрос к API только когда пользователь перестал печатать
const search = debounce((query) => {
console.log('Отправляем запрос:', query);
// fetch(`/api/search?q=${encodeURIComponent(query)}`)...
}, 400);
document.querySelector('#search').addEventListener('input', (e) => {
search(e.target.value);
});
Ключевой момент — clearTimeout(timeoutId) в начале. Пока события идут плотным потоком, таймер постоянно сбрасывается и колбэк не запускается. Как только наступает пауза длиннее delay — срабатывает последний запланированный вызов.
Debounce с немедленным первым вызовом
Иногда нужно отреагировать сразу на первое событие, а последующие — игнорировать до паузы. Например, кнопка «Сохранить», которую защищаем от двойного клика. Добавим флаг immediate:
function debounce(fn, delay = 300, immediate = false) {
let timeoutId;
return function (...args) {
// нужно ли вызвать прямо сейчас (таймера ещё нет)
const callNow = immediate && !timeoutId;
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
timeoutId = null;
// при immediate=false вызываем по хвосту паузы
if (!immediate) fn.apply(this, args);
}, delay);
if (callNow) fn.apply(this, args);
};
}
// Сработает мгновенно на первый клик, повторные в течение 1с — проигнорирует
const save = debounce(() => console.log('Сохранено'), 1000, true);
Реализация throttle
Здесь храним отметку времени последнего вызова. Если с прошлого вызова прошло меньше limit миллисекунд — пропускаем. Это самый дешёвый вариант — «leading edge», вызов происходит в начале интервала.
// throttle: функция выполняется не чаще раза в limit мс
function throttle(fn, limit = 200) {
let lastCall = 0;
return function (...args) {
const now = Date.now();
// ещё не прошёл интервал — выходим, ничего не делая
if (now - lastCall < limit) return;
lastCall = now;
fn.apply(this, args);
};
}
// Применение: обновляем позицию при скролле максимум раз в 150 мс
const onScroll = throttle(() => {
const progress = window.scrollY / document.body.scrollHeight;
console.log('Прокрутка:', Math.round(progress * 100) + '%');
}, 150);
window.addEventListener('scroll', onScroll);
У этой простой версии есть нюанс: последний вызов в серии может потеряться. Если события прекратились внутри интервала, финальное состояние не обработается. Для скролла это часто терпимо, но для надёжности добавляют «trailing edge» — гарантированный вызов в конце.
Throttle с trailing-вызовом
function throttle(fn, limit = 200) {
let lastCall = 0;
let timeoutId = null;
return function (...args) {
const now = Date.now();
const remaining = limit - (now - lastCall);
if (remaining <= 0) {
// интервал прошёл — вызываем немедленно
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
lastCall = now;
fn.apply(this, args);
} else if (!timeoutId) {
// планируем финальный вызов на конец интервала
timeoutId = setTimeout(() => {
lastCall = Date.now();
timeoutId = null;
fn.apply(this, args);
}, remaining);
}
};
}
Теперь функция и срабатывает регулярно во время серии событий, и гарантированно вызывается ещё раз после её окончания — последнее значение не теряется.
requestAnimationFrame вместо throttle для анимаций
Если throttle нужен ради плавной отрисовки (параллакс, прогресс-бар, перетаскивание), привязывайтесь не к таймеру, а к кадрам браузера через requestAnimationFrame. Это синхронизирует обновления с частотой экрана (обычно 60 FPS) и не выполняет лишней работы между кадрами:
// throttle на основе кадров отрисовки — идеален для scroll/resize с DOM-правками
function rafThrottle(fn) {
let scheduled = false;
return function (...args) {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
fn.apply(this, args);
scheduled = false;
});
};
}
const onResize = rafThrottle(() => {
// тяжёлый пересчёт раскладки выполнится максимум раз за кадр
document.body.dataset.width = window.innerWidth;
});
window.addEventListener('resize', onResize);
Между двумя кадрами все лишние события «схлопываются» в один вызов. Это правильный выбор, когда колбэк трогает DOM или CSS — браузер всё равно перерисовывает не чаще, чем раз в кадр.
Не забывайте про отписку
В SPA (Vue, React) обработчики нужно снимать при размонтировании компонента, иначе утечки и лишние вызовы. Для этого сохраняйте ссылку на debounce/throttle-функцию:
const handler = debounce(updateList, 300);
// подписка
input.addEventListener('input', handler);
// при уничтожении компонента
input.removeEventListener('input', handler);
// в Vue 3 (Composition API):
// onMounted(() => window.addEventListener('scroll', handler));
// onUnmounted(() => window.removeEventListener('scroll', handler));
Если же вы пишете на removeEventListener новую анонимную функцию — отписка не сработает, потому что это будет другая ссылка. Всегда снимайте тот же объект, что вешали.
Чеклист: что выбрать
- Поиск, автодополнение, валидация формы — debounce (300–500 мс). Запрос уходит, когда пользователь закончил ввод.
- Кнопки и защита от дабл-клика — debounce с
immediate=true. - Скролл, бесконечная подгрузка, sticky-элементы — throttle (100–200 мс) или
rafThrottle, если меняете DOM. - Параллакс, drag&drop, анимации по событию —
requestAnimationFrame. - resize окна — throttle или rafThrottle, чтобы не пересчитывать раскладку на каждый пиксель.
- Не хотите писать руками — в
lodashесть готовые_.debounceи_.throttleс опциямиleading/trailing, но для одного-двух обработчиков своя реализация в 10 строк избавит от лишней зависимости.
Понимание разницы между этими двумя приёмами — базовый навык фронтендера. Debounce ждёт тишины и реагирует один раз, throttle держит ритм. Выберите правильный — и интерфейс перестанет тормозить даже на слабых устройствах.
