Skip to main content

Що таке throttling?

Throttling - це обмеження частоти виклику функції: не більше одного виклику за визначений проміжок часу, решта відхиляється.

Теорія

TL;DR

  • Аналогія: кав'ярня з одним бариста. Одне замовлення за хвилину проходить, решта відхиляються.
  • Throttle викликає функцію рівномірно під час бурсту. Debounce чекає тишу і викликає один раз.
  • Правило вибору: потрібні регулярні оновлення під час активності (scroll, live-пошук)? Throttle. Потрібен лише фінальний стан після зупинки? Debounce.
  • 16ms throttle = 60fps. Це число для обробників scroll і resize.
  • Стан зберігається через замикання (closure), зовнішні бібліотеки не потрібні.

Короткий приклад

javascript
function throttle(fn, delay) { let lastCall = 0; return function(...args) { const now = Date.now(); if (now - lastCall >= delay) { lastCall = now; return fn(...args); } // Виклики всередині вікна delay ігноруються }; } const log = throttle(() => console.log('Викликано'), 1000); log(); // Викликано (0ms) setTimeout(log, 500); // Ігнорується (500ms < 1000ms) setTimeout(log, 1000); // Викликано (1000ms >= 1000ms)

Функція закриває lastCall у собі через замикання. Кожен виклик перевіряє час і або виконується, або повертається одразу. Жодних таймерів, жодних черг. Тільки перевірка часової мітки.

Throttle vs debounce

Throttle задає рівномірний ритм, як метроном. Під час бурсту функція виконується через рівні проміжки. Debounce пригнічує всі виклики до зупинки активності і потім виконується один раз. Якщо користувач друкує п'ять секунд, throttle із затримкою 200ms дасть близько 25 preview-викликів. Debounce із 500ms дасть один виклик через пів секунди після останнього символу. Обидва обмежують навантаження, але throttle зберігає рівномірний часовий ряд під час активності.

Коли використовувати throttle

  • Обробники scroll і resize можуть спрацьовувати сотні разів на секунду. Throttle до 16ms для рендерингу в 60fps без layout thrashing.
  • Пошук під час введення: throttle до 200-300ms для live-превʼю без зайвих запитів.
  • Захист на стороні клієнта від rate limit API. GitHub дозволяє 5000 запитів/год на токен, і один бурст може вичерпати цей ліміт.
  • Датчики або ігрові цикли з високою частотою подій.
  • Якщо потрібен лише фінальний стан (валідація після втрати фокусу, автозавершення після паузи), краще debounce.

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

Базова реалізація на основі часової мітки: зчитати Date.now(), порівняти з lastCall, пропустити якщо різниця менша за delay. Жоден таймер не запускається. Черги немає. Пробіл між викликами залежить тільки від того, коли наступний виклик прийде після завершення вікна.

Варіант на основі setTimeout працює інакше. Перший виклик запускає таймер і ігнорує решту до його спрацювання. Це trailing-edge реалізація: виконання відбувається після затримки, а не до неї. Потрібна тоді, коли важливо щоб пройшли найсвіжіші аргументи, а не перші у бурсті.

Браузери обробляють setTimeout у черзі макрозадач, тому реальна затримка може бути трохи більша під навантаженням. Node.js використовує libuv-таймери, які мають ту ж особливість. Для точного вимірювання часу краще performance.now(): він монотонний, доступний і в браузері, і в Node починаючи з версії 8.5.

Leading та trailing edges

Базовий timestamp-throttle спрацьовує на leading edge: перший виклик у бурсті проходить одразу. Trailing edge спрацьовує після затримки з найсвіжішими аргументами. Lodash _.throttle підтримує обидва варіанти через опції.

javascript
function advancedThrottle(fn, delay, { leading = true, trailing = true } = {}) { let lastCall = 0, timeoutId, pendingArgs; return function(...args) { const now = Date.now(); const timeLeft = delay - (now - lastCall); if (leading && timeLeft <= 0) { lastCall = now; fn(...args); // Leading: одразу } else if (trailing) { pendingArgs = args; clearTimeout(timeoutId); timeoutId = setTimeout(() => { if (pendingArgs) { lastCall = Date.now(); fn(...pendingArgs); // Trailing: з найсвіжішими аргументами pendingArgs = null; } }, timeLeft > 0 ? timeLeft : delay); } }; } const apiCall = advancedThrottle(console.log, 1000); apiCall('перший'); // Leading: 0ms setTimeout(() => apiCall('другий'), 500); // Trailing: 1000ms

Якщо увімкнені обидва режими, один виклик спрацює двічі: одразу і після затримки. Це часто дивує.

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

Помилка 1: Рекурсивний setTimeout без перевірки часу

javascript
// Неправильно: новий таймер при кожному виклику без блокування function badThrottle(fn, delay) { let timeoutId; return () => { timeoutId = setTimeout(fn, delay); // Перезаписує посилання, але функція все одно спрацьовує }; }

Кожен виклик додає новий таймер. Під бурстом вони накопичуються і виконуються разом із дрейфом. Виправлення: відстежувати lastCall і повертатись одразу, якщо час не минув.

Помилка 2: Throttle для async-функцій без врахування in-flight запитів

javascript
// Неправильно: не враховує чи завершився попередній запит const throttledFetch = throttle(async (url) => { const data = await fetch(url); // Виконується 2+ секунди console.log(data); }, 1000); throttledFetch('/slow'); // Починає виконання // Через 1001ms throttle пропускає наступний, але перший ще не завершився throttledFetch('/slow');

Throttle контролює частоту викликів, але не паралелізм. Якщо функція виконується довше ніж delay, запити перекриватимуться. Для комбінованого контролю частоти та паралелізму використовуй p-throttle.

Помилка 3: Відсутність cleanup при розмонтуванні React-компонента

javascript
// Неправильно: таймер спрацює після видалення компонента useEffect(() => { window.addEventListener('scroll', throttledHandler); // Немає cleanup = виклики після розмонтування }, []); // Правильно: useEffect(() => { window.addEventListener('scroll', throttledHandler); return () => window.removeEventListener('scroll', throttledHandler); }, []);

Виклики після розмонтування призводять до попередження "setState on unmounted component". Зберігай посилання на функцію в useRef і викликай clearTimeout у cleanup.

Помилка 4: Один загальний екземпляр throttle для кількох незалежних споживачів

javascript
// Неправильно: усі ділять спільний lastCall const sharedThrottle = throttle(fn, 1000); items.forEach(item => sharedThrottle(item)); // Перший елемент блокує всіх

Кожен незалежний споживач потребує власного екземпляра throttle зі своїм замиканням. Створюй per-компонент або per-фіча, а не глобально.

Де зустрічається

  • VS Code: Lodash throttle на live-лінтері. Парсинг запускається не частіше одного разу за delay під час введення.
  • GitHub API: 5000 запитів/год на токен. Throttle на стороні клієнта запобігає вичерпанню одним бурстом.
  • React Window: обробник scroll обмежений 16ms для рендерингу великих списків.
  • Netflix web: RxJS throttleTime на події scroll в UI-шарі.
  • Vercel serverless: p-throttle для обмеження паралельних запитів до бази даних.

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

Q: Яка різниця між throttle і debounce для поля пошуку?
A: Throttle викликає функцію кожні 200ms під час введення, даючи регулярні preview-результати. Debounce чекає 500ms після останнього символу і викликає один раз. Throttle для постійного зворотного зв'язку, debounce для одного фінального запиту.

Q: Реалізуй throttle з підтримкою leading і trailing edge.
A: Відстежуй lastCall. При кожному виклику обчислюй timeLeft = delay - (now - lastCall). Якщо timeLeft <= 0, виконай одразу (leading) і оновлюй lastCall. Інакше збережи останні аргументи і встанови таймер на timeLeft (trailing). Скидай попередній таймер при кожному новому виклику.

Q: Як throttle поводиться з async-функціями?
A: Не чекає завершення попереднього виклику. Два виклики через 1001ms обидва пройдуть, навіть якщо перший ще не resolved. Для послідовного async-контролю використовуй p-throttle або керуй чергою промісів вручну.

Q: Як event loop в Node.js впливає на throttle-таймери?
A: libuv-таймери не гарантують точне спрацювання. Під навантаженням setTimeout(fn, 16) може спрацювати через 20ms або пізніше. Для точного контролю перевіряй performance.now() вручну, а не покладайся на момент спрацювання таймера.

Q: (Senior) Спроектуй розподілений throttle для мікросервісів. Чому token bucket краще ніж fixed window?
A: Fixed window дозволяє бурст на межі: 100 запитів/хв означає 100 в кінці однієї хвилини і 100 на початку наступної, тобто 200 за дві секунди. Token bucket вирівнює потік: токени поповнюються з фіксованою швидкістю незалежно від позиції в межах вікна. В Redis: INCR + EXPIRE для fixed window, Lua-скрипт з логікою поповнення для token bucket. Bucket запобігає бурстам на межах повністю.

Приклади

Базова реалізація throttle

javascript
function throttle(fn, delay) { let lastCall = 0; return function(...args) { const now = Date.now(); if (now - lastCall >= delay) { lastCall = now; return fn(...args); } }; } // Сценарій: кнопка збереження з API-запитом const saveDocument = throttle((content) => { fetch('/api/save', { method: 'POST', body: JSON.stringify({ content }) }); console.log('Збережено о', Date.now()); }, 2000); saveDocument('чернетка 1'); // Виконується (0ms) saveDocument('чернетка 2'); // Пропускається (400ms) saveDocument('чернетка 3'); // Пропускається (800ms) saveDocument('чернетка 4'); // Пропускається (1200ms) // Через 2000ms: saveDocument('чернетка 5'); // Виконується (2000ms)

Функція зберігає не частіше одного разу на 2 секунди. Проміжні чернетки відхиляються. Якщо потрібно щоб остання чернетка завжди зберігалась, додай trailing edge: збережи останні аргументи і виконай їх після завершення вікна.

Throttle для scroll у React-компоненті

javascript
import { useCallback, useEffect, useState } from 'react'; function throttle(fn, delay) { let timeoutId = null; return (...args) => { if (!timeoutId) { timeoutId = setTimeout(() => { timeoutId = null; fn(...args); }, delay); } }; } function VirtualList({ items }) { const [visibleRange, setVisibleRange] = useState({ start: 0, end: 20 }); const handleScroll = useCallback( throttle((e) => { const scrollTop = e.target.scrollTop; const itemHeight = 40; const start = Math.floor(scrollTop / itemHeight); const end = start + 15; // видимі + буфер setVisibleRange({ start, end }); }, 16), // ~60fps [] ); useEffect(() => { return () => window.removeEventListener('scroll', handleScroll); }, [handleScroll]); return ( <div onScroll={handleScroll} style={{ height: 400, overflow: 'auto' }}> {items.slice(visibleRange.start, visibleRange.end).map(item => ( <div key={item.id} style={{ height: 40 }}>{item.name}</div> ))} </div> ); } // Без throttle: scroll спрацьовує 1000+ разів/сек // З throttle 16ms: ~60 викликів/сек, плавний рендеринг

Цей варіант на основі setTimeout спрацьовує на trailing edge: перша подія scroll чекає 16ms перед виконанням. У scroll-важких інтерфейсах 16ms - єдине значення затримки, яке дає плавний scroll без видимих підвисань в продакшені.

Розподілений rate limiter на Redis (token bucket)

javascript
// Node.js + Redis: token bucket для мікросервісів const redis = require('redis'); const client = redis.createClient(); async function tokenBucketThrottle(userId, maxTokens, refillRate) { const key = `throttle:${userId}`; const now = Date.now(); const luaScript = ` local key = KEYS[1] local maxTokens = tonumber(ARGV[1]) local refillRate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local data = redis.call('HMGET', key, 'tokens', 'lastRefill') local tokens = tonumber(data[1]) or maxTokens local lastRefill = tonumber(data[2]) or now local elapsed = (now - lastRefill) / 1000 tokens = math.min(maxTokens, tokens + elapsed * refillRate) if tokens >= 1 then tokens = tokens - 1 redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', now) redis.call('EXPIRE', key, 3600) return 1 else return 0 end `; const allowed = await client.eval(luaScript, 1, key, maxTokens, refillRate, now); return allowed === 1; } // Middleware для Express: throttle per-user app.use(async (req, res, next) => { const allowed = await tokenBucketThrottle( req.user.id, 100, // Максимум 100 токенів у бакеті 1.67 // Поповнення 1.67 токена/сек = 100/хв ); if (!allowed) { return res.status(429).json({ error: 'Ліміт запитів вичерпано' }); } next(); });

Token bucket розподіляє навантаження рівномірно незалежно від того, коли в межах хвилини приходять запити. Fixed window такої гарантії не дає: 100 запитів в 0:59 і ще 100 в 1:00 пропустить без жодних обмежень.

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

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

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

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