Skip to main content

Дебаунс та тротл у JavaScript

Debounce відкладає виклик функції до паузи в подіях. Throttle обмежує виконання до одного разу за заданий проміжок часу.

Теорія

Коротко

  • Debounce чекає тиші: спрацьовує один раз після того, як виклики зупинились
  • Throttle тримає ритм: спрацьовує з фіксованою частотою під час неперервної активності
  • Аналогія: debounce - це туалет із замком на 5 хвилин після кожного використання; throttle - кава, яку підливають максимум раз на 5 хвилин
  • Користувач зупиняється, потім діє? Debounce. Потрібна стабільна частота? Throttle.

Швидкий приклад

javascript
// Debounce: спрацьовує один раз після зупинки викликів const debounce = (fn, delay) => { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; }; // Throttle: спрацьовує не частіше ніж раз на delay мс const throttle = (fn, delay) => { let lastCall = 0; return (...args) => { const now = Date.now(); if (now - lastCall >= delay) { lastCall = now; fn(...args); } }; };

Обидва патерни базуються на замиканнях (closures). debounce скидає таймер при кожному виклику. throttle перевіряє мітку часу і відкидає виклики, що надходять занадто рано.

Головна різниця

Debounce збирає серію викликів і виконує функцію рівно один раз, після того як серія закінчується. Саме це слово тут головне. Throttle не чекає кінця серії. Він спрацьовує одразу, потім ще раз через кожен інтервал, поки виклики продовжуються.

Коли що вибирати

  • Користувач вводить текст у пошук → debounce (один API-запит після паузи, не на кожну літеру)
  • Прокрутка або зміна розміру вікна → throttle (оновлення інтерфейсу з постійним темпом)
  • Кнопка відправлення форми → throttle (захист від повторних відправлень)
  • Зовнішній API з лімітом на запити → throttle (рівномірно розподіляємо навантаження)
  • Валідація поля під час введення → debounce (перевірка після паузи, не по-середині слова)

Таблиця порівняння

АспектDebounceThrottle
Коли спрацьовуєПісля останнього виклику + delayОдразу, потім раз на інтервал
При частих викликахСкидається щоразу, спрацьовує в кінціСпрацьовує з фіксованим інтервалом
Кількість виконаньЗазвичай 1 на серіюКілька, обмежених за частотою
Стандартна поведінкаTrailing (після тиші)Leading (перший виклик одразу)
Підходить дляАвтодоповнення, автозбереженняScroll, mousemove, API polling

Як це працює всередині

debounce викликає clearTimeout при кожному виклику. Це скасовує попередній запланований callback. Виживає тільки останній виклик, бо йому вже нічого не заважає. throttle в базовому варіанті не використовує таймер взагалі. Він порівнює Date.now() із часом останнього виконання. Якщо різниця менша за delay, виклик відкидається.

Помилка, яку я сам робив на початку: думав, що throttle завжди пропускає перший виклик. Ні. Перший проходить одразу, бо lastCall починається з 0, а Date.now() - 0 завжди більше будь-якого реального delay.

Обидва патерни залежать від event loop браузера. Callback-и таймерів потрапляють у чергу macrotask і виконуються після поточного синхронного коду.

Типові помилки

Помилка 1: Немає очищення при розмонтуванні в React

javascript
// Неправильно: таймер спрацьовує після видалення компонента useEffect(() => { debouncedFetch(query); }, [query]); // Правильно: скасовуємо таймер при очищенні useEffect(() => { const timer = setTimeout(() => fetchData(query), 300); return () => clearTimeout(timer); }, [query]);

Без очищення таймер викликає setState на вже розмонтованому компоненті. Це React-попередження в dev-режимі і потенційна проблема при SSR.

Помилка 2: Нова debounce-функція при кожному рендері

javascript
// Неправильно: кожен рендер створює нову функцію, таймер постійно скидається function SearchInput() { const handleChange = debounce((val) => search(val), 500); return <input onChange={handleChange} />; } // Правильно: створюємо один раз function SearchInput() { const handleChange = useMemo( () => debounce((val) => search(val), 500), [] ); return <input onChange={handleChange} />; }

Помилка 3: Очікування, що throttle пропустить перший виклик

javascript
const throttled = throttle(fn, 1000); throttled(); // Спрацьовує одразу (leading edge) throttled(); // Пропускається throttled(); // Пропускається

Lodash _.throttle за замовчуванням спрацьовує на leading edge. Передай { leading: false }, якщо потрібна інша поведінка.

Помилка 4: Втрата контексту this

javascript
// Неправильно: `this` губиться всередині обгортки function debounce(fn, delay) { let timer; return function() { clearTimeout(timer); timer = setTimeout(fn, delay); // `this` не передається }; } // Правильно function debounce(fn, delay) { let timer; return function(...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); // ✅ }; }

Помилка 5: Вкладення debounce в throttle

javascript
// Зламана конструкція: внутрішній debounce скидається при кожному виклику throttle const broken = throttle(() => debounce(save, 500)(), 1000); // save ніколи не викликається

Одна обгортка завжди краще двох вкладених. Вкладення призводить до постійного скидання внутрішнього таймера.

Де зустрічається в реальних проектах

  • Пошукові поля в React використовують хук use-debounce (той самий патерн, що у шаблонах Vercel commerce)
  • Lodash _.debounce і _.throttle є практично в кожному великому npm-проекті для обробки scroll та input
  • express-rate-limit в Node.js/Express реалізує throttle-патерн для API-ендпоінтів
  • Для scroll і resize на 60fps requestAnimationFrame часто краще за throttle з фіксованим delay
  • Нативний ResizeObserver у сучасних браузерах позбавляє потреби вручну throttle-ити resize-події

Питання на співбесіді

Q: Реалізуй debounce з нуля.
A: Замикання (closure), clearTimeout і setTimeout. Передай this та args через .apply(this, args). Базова реалізація є у "Швидкому прикладі" вище.

Q: Що станеться, якщо delay дорівнює 0?
A: Debounce виконає функцію на наступному тику event loop після кожного виклику, тобто один раз на task. Throttle пропустить всі виклики, бо Date.now() - 0 завжди більше або рівне 0.

Q: Як уникнути memory leak від debounce в React?
A: Повертай функцію очищення з useEffect, яка викликає clearTimeout. Зберігай ID таймера в useRef, щоб він не зникав між рендерами.

Q: Яка різниця між leading і trailing edge у throttle?
A: Leading спрацьовує одразу при першому виклику. Trailing спрацьовує в кінці інтервалу з останніми аргументами. Lodash _.throttle робить обидва за замовчуванням, що дає зайвий виклик наприкінці серії.

Q: Як зробити debounce для async-функції?
A: Обгортка повертає void, не Promise. Щоб отримати результат, передай його через callback або стеж за останнім Promise через ref і резолви його окремо. Інакше кілька паралельних запитів можуть завершитися в непередбачуваному порядку.

Приклади

Базовий: debounce для поля пошуку

javascript
const debounce = (fn, delay) => { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; }; const input = document.querySelector('#search'); const search = debounce((event) => { console.log('API запит:', event.target.value); // fetch(`/api/search?q=${event.target.value}`) }, 400); input.addEventListener('input', search); // Введення 'hello' = 5 подій = 1 API-запит через 400мс після паузи

Без debounce введення п'яти символів дає п'ять API-запитів. З ним - один, після того як користувач зупинився.

Середній рівень: throttle для infinite scroll

javascript
const throttle = (fn, delay) => { let lastCall = 0; return (...args) => { const now = Date.now(); if (now - lastCall >= delay) { lastCall = now; fn(...args); } }; }; const handleScroll = throttle(() => { const nearBottom = window.scrollY + window.innerHeight >= document.body.scrollHeight - 100; if (nearBottom) { loadMoreItems(); } }, 200); window.addEventListener('scroll', handleScroll); // Максимум 5 перевірок на секунду незалежно від швидкості прокрутки

Швидка прокрутка без throttle генерує сотні подій на секунду. При 200мс - максимум 5 перевірок. Цього достатньо, щоб infinite scroll спрацьовував без помітних затримок.

Просунутий рівень: React хук із правильним очищенням

javascript
import { useCallback, useRef, useEffect } from 'react'; function useDebounce(callback, delay) { const timerRef = useRef(null); useEffect(() => { return () => clearTimeout(timerRef.current); }, []); return useCallback((...args) => { clearTimeout(timerRef.current); timerRef.current = setTimeout(() => callback(...args), delay); }, [callback, delay]); } function SearchBox() { const handleSearch = useDebounce((value) => { fetch(`/api/search?q=${value}`).then((res) => res.json()); }, 300); return ( <input type='text' onChange={(e) => handleSearch(e.target.value)} placeholder='Пошук...' /> ); } // useEffect cleanup скасовує таймер при розмонтуванні // Немає спроб оновити стан видаленого компонента

Очищення в useEffect - це деталь, яку найчастіше пропускають у власних реалізаціях. Без неї користувач, який швидко перейде на іншу сторінку, отримає fetch-запит, що намагається оновити стан вже неіснуючого компонента.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?