Skip to main content

What is throttling?

Throttling limits how often a function can execute, allowing at most one call per defined time window and dropping any calls that arrive too soon.

Theory

TL;DR

  • Like a coffee shop with one barista: one order gets served per minute, extras are dropped.
  • Throttle fires at fixed intervals during a burst. Debounce waits for silence, then fires once.
  • Decision rule: need periodic updates during ongoing activity (scroll, live search)? Use throttle. Need only the final value after the user stops? Use debounce.
  • 16ms throttle = 60fps. That is the number to know for scroll and resize handlers.
  • State is stored via closure, no extra libraries needed.

Quick example

javascript
function throttle(fn, delay) { let lastCall = 0; return function(...args) { const now = Date.now(); if (now - lastCall >= delay) { lastCall = now; return fn(...args); } // Calls within the delay window are dropped }; } const log = throttle(() => console.log('Called'), 1000); log(); // Called (0ms) setTimeout(log, 500); // Ignored (500ms < 1000ms delay) setTimeout(log, 1000); // Called (1000ms >= 1000ms delay)

The function closes over lastCall via lexical scope. Each invocation checks elapsed time and either executes or exits early. No timers, no queues. Just a timestamp guard.

Throttle vs debounce

Throttle enforces a fixed cadence, like a metronome. During rapid firing, the function runs at steady intervals. Debounce suppresses all calls until activity stops, then fires once. If a user types for five seconds, throttle at 200ms gives around 25 preview calls. Debounce at 500ms gives one call, half a second after the last keystroke. Both prevent overload, but throttle preserves sampling from ongoing events.

When to use throttle

  • Scroll and resize handlers fire hundreds of times per second. Throttle to 16ms for smooth 60fps rendering without layout thrashing.
  • Search-as-you-type: throttle to 200-300ms for live API previews without hammering the server on every keystroke.
  • Client-side protection before sending to a rate-limited API. GitHub allows 5000 requests per hour per token, and a single burst can exhaust that.
  • High-frequency sensors or game loops where processing every event would stall the thread.
  • If you only need the final state (form validation on blur, autocomplete after a pause), debounce is the better fit.

Internal mechanism

The timestamp-based throttle is the simplest version. On each call, read Date.now(), compare to lastCall, skip if the delta is less than delay. No timer fires. No queue grows. The gap between executions depends only on when the next call arrives after the window expires.

The timeout-based variant works differently. It sets a setTimeout on the first call and ignores all others until the timer fires. This is a trailing-edge implementation: execution happens after the delay, not before. It is what you want when the most recent arguments matter more than the earliest ones in a burst.

Browsers process setTimeout callbacks in the macrotask queue, so the actual delay can be slightly longer than specified under heavy load. Node.js uses libuv timers, which can drift the same way. For precision-sensitive cases, performance.now() gives a monotonic high-resolution timestamp. It behaves the same in both browser and Node since Node v8.5.

Leading and trailing edges

The default timestamp throttle fires on the leading edge: the first call in a burst goes through immediately. The trailing edge fires after the delay, using the most recent arguments. Lodash _.throttle supports both via options.

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); // Fires immediately on leading edge } else if (trailing) { pendingArgs = args; clearTimeout(timeoutId); timeoutId = setTimeout(() => { if (pendingArgs) { lastCall = Date.now(); fn(...pendingArgs); // Fires with latest args on trailing edge pendingArgs = null; } }, timeLeft > 0 ? timeLeft : delay); } }; } const apiCall = advancedThrottle(console.log, 1000); apiCall('first'); // Leading: fires at 0ms setTimeout(() => apiCall('second'), 500); // Trailing: fires at 1000ms

A single call with both options enabled fires twice: once immediately, once after the delay. That trips up a lot of people the first time they see it.

Common mistakes

Mistake 1: Recursive setTimeout without a timestamp guard

javascript
// Wrong: new timer on every call, no blocking logic function badThrottle(fn, delay) { let timeoutId; return () => { timeoutId = setTimeout(fn, delay); // Overwrites ref but still schedules fn }; }

Every rapid call spawns a new timer. They accumulate, drift slightly, and execute in a cluster. The fix: track lastCall and return early if the window has not elapsed.

Mistake 2: Throttling async functions without accounting for in-flight requests

javascript
// Wrong: second call fires before first resolves const throttledFetch = throttle(async (url) => { const data = await fetch(url); // Takes 2+ seconds console.log(data); }, 1000); throttledFetch('/slow'); // Starts, takes 2s // 1001ms later - throttle allows this, but first is still running throttledFetch('/slow');

Throttle controls call rate, not concurrency. If the async function takes longer than the delay, in-flight operations overlap. Use p-throttle for combined rate and concurrency control.

Mistake 3: Forgetting cleanup on React component unmount

javascript
// Wrong: timer fires after component is gone useEffect(() => { window.addEventListener('scroll', throttledHandler); // No cleanup = ghost calls after unmount }, []); // Correct: useEffect(() => { window.addEventListener('scroll', throttledHandler); return () => window.removeEventListener('scroll', throttledHandler); }, []);

Ghost calls after unmount cause "setState on unmounted component" warnings. Store the throttle result in a useRef and clear the timeout in the cleanup function.

Mistake 4: One global throttle instance shared across independent callers

javascript
// Wrong: all callers share the same lastCall const sharedThrottle = throttle(fn, 1000); items.forEach(item => sharedThrottle(item)); // First item blocks all others

Each independent call site needs its own throttle instance with its own closure. Create it per component or per feature, not globally, unless blocking every caller at once is the actual goal.

Real-world usage

  • VS Code: Lodash throttle on the live linter. Parse runs at most once per configured delay as you type, not on every keystroke.
  • GitHub API: 5000 requests per hour per token. Client-side throttle prevents exhausting that limit in one burst.
  • React Window: scroll handler throttled to 16ms for virtualized list rendering.
  • Netflix web app: RxJS throttleTime operator on scroll events in the UI layer.
  • Vercel serverless: p-throttle to cap concurrent database calls from lambda functions.

Follow-up questions

Q: What is the difference between throttle and debounce for a search input?
A: Throttle fires every 200ms while the user types, giving periodic previews. Debounce waits 500ms after the last keystroke and fires once. Pick throttle for continuous feedback, debounce for one final request.

Q: Implement throttle with both leading and trailing edge support.
A: Track lastCall. On each call, compute timeLeft = delay - (now - lastCall). If timeLeft <= 0, fire immediately (leading) and update lastCall. Otherwise, store the latest args and set a timeout for timeLeft (trailing). Clear the previous timeout on each new call to avoid stacking.

Q: How does throttle behave with async functions?
A: It does not await the previous call. Two calls 1001ms apart both go through even if the first is still resolving. For serial async control, use p-throttle or manage a promise queue manually.

Q: In Node.js, how does the event loop affect throttled timers?
A: libuv timers are not guaranteed to fire exactly on time. Under CPU load, a 16ms timeout might fire at 20ms or later. For tight precision, check performance.now() manually rather than relying on the callback timing.

Q: (Senior) Design a distributed throttle for microservices. Why token bucket over fixed window?
A: Fixed window allows boundary bursts: 100 req/min means 100 at 0:59 and 100 more at 1:00, so 200 in two seconds. Token bucket smooths this by refilling tokens at a fixed rate regardless of window position. In Redis, use INCR with EXPIRE for fixed window, or a Lua script with token refill logic for token bucket. The bucket prevents boundary spikes entirely.

Examples

Basic throttle from scratch

javascript
function throttle(fn, delay) { let lastCall = 0; return function(...args) { const now = Date.now(); if (now - lastCall >= delay) { lastCall = now; return fn(...args); } }; } // Scenario: save button that triggers an API call const saveDocument = throttle((content) => { fetch('/api/save', { method: 'POST', body: JSON.stringify({ content }) }); console.log('Saved at', Date.now()); }, 2000); saveDocument('draft 1'); // Fires (0ms) saveDocument('draft 2'); // Dropped (400ms) saveDocument('draft 3'); // Dropped (800ms) saveDocument('draft 4'); // Dropped (1200ms) // 2000ms later: saveDocument('draft 5'); // Fires (2000ms)

The function saves at most once every two seconds. Intermediate drafts are dropped. If you need the last draft to always go through, add trailing edge support so the final call in a burst still executes after the delay.

React scroll handler for a virtualized list

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; // visible + buffer 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> ); } // Without throttle: scroll fires 1000+ times/sec // With 16ms throttle: ~60 calls/sec, smooth rendering

This timeout-based throttle fires on the trailing edge. The first scroll event waits 16ms before executing. In scroll-heavy UIs like this one, 16ms is the only throttle value I have seen hold up without visible jank in production. Anything higher and the list lags behind the finger.

Distributed rate limiter using Redis token bucket

javascript
// Node.js + Redis: token bucket throttle for microservices 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; } // Express middleware: GitHub-style per-user throttling app.use(async (req, res, next) => { const allowed = await tokenBucketThrottle( req.user.id, 100, // Max 100 tokens in the bucket 1.67 // Refill 1.67 tokens/sec = 100/min ); if (!allowed) { return res.status(429).json({ error: 'Rate limit exceeded' }); } next(); });

Token bucket distributes load smoothly across the entire minute. A fixed window would allow 100 requests at 0:59 and 100 more at 1:00, producing a burst of 200 in two seconds. The bucket prevents that by controlling the flow rate rather than counting within a window.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?