What throttling actually is
Throttling caps how often a function may run: at most once per interval, no matter how many times it is triggered. Unlike debouncing, throttling does not wait for events to stop. It lets work through on a steady cadence for as long as events keep arriving, discarding or coalescing the calls in between.
The mental model is a valve. Events pour in at whatever rate the user or the network produces them; the valve releases one call per tick. This gives you a guarantee debouncing cannot: under sustained load, work still makes progress, at a predictable rate.
The timeline mental model
Scroll events fire dozens of times per second. If your handler reads layout properties and writes styles, that is dozens of forced reflows per second and a visibly janky page. Throttling to one call per animation frame — or per 100 ms for cheaper work — keeps behaviour smooth while still tracking the user continuously.
Leading and trailing behaviour
Like debounce, throttle has edges. A leading throttle fires immediately on the first event of each window; a trailing throttle fires at the end of the window using the most recent arguments. Most real implementations do both: fire immediately, then fire once more at the end of the window if events arrived while it was closed. Trailing matters because without it, the last event in a burst can be dropped — the scroll handler stops updating slightly before the user actually stopped scrolling.
Implementing throttle
Timestamp version
function throttle(fn, interval = 200) {
let last = 0;
return function throttled(...args) {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn.apply(this, args);
}
};
}Timer version with trailing call
function throttle(fn, interval = 200, { leading = true, trailing = true } = {}) {
let last = 0;
let timer = null;
let lastArgs = null;
return function throttled(...args) {
const now = Date.now();
if (!last && !leading) last = now;
const remaining = interval - (now - last);
if (remaining <= 0) {
if (timer) { clearTimeout(timer); timer = null; }
last = now;
fn.apply(this, args);
} else if (trailing && !timer) {
lastArgs = args;
timer = setTimeout(() => {
last = leading ? Date.now() : 0;
timer = null;
fn.apply(this, lastArgs);
}, remaining);
} else if (trailing) {
lastArgs = args; // keep the freshest arguments for the trailing call
}
};
}The detail people miss is lastArgs. A trailing call should use the latest event data, not the data from whenever the timer was scheduled. Getting this wrong produces a scroll indicator that lands one position behind where the user actually stopped.
requestAnimationFrame throttling
For anything that touches layout or paint — scroll position, parallax, drag handles, resize-driven measurement — a fixed millisecond interval is the wrong unit. The right unit is the frame. Throttling with requestAnimationFrame aligns your work with the browser's render loop, so you never compute a value that is thrown away before it is painted, and the browser automatically pauses the work in background tabs.
function rafThrottle(fn) {
let queued = false;
let lastArgs = null;
return function throttled(...args) {
lastArgs = args;
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
fn.apply(this, lastArgs);
});
};
}
window.addEventListener("scroll", rafThrottle(updateHeaderState), { passive: true });Two adjacent techniques are worth knowing. IntersectionObserver replaces most "is this element visible" scroll handlers entirely, and ResizeObserver replaces most resize listeners — both are already batched by the browser and are cheaper than any throttle you can write.
Throttling on the server
On the backend, throttling is called rate limiting, and it is a core availability mechanism rather than a performance nicety. Its jobs are to protect capacity, enforce fair use between tenants, contain abuse and credential stuffing, and control spend on paid downstream APIs.
- Per-user or per-API-key limits — the standard quota model, e.g. 1,000 requests per hour.
- Per-IP limits — the first line of defence for unauthenticated endpoints such as login and signup.
- Per-endpoint limits — expensive endpoints (search, report generation, LLM calls) get tighter budgets than cheap ones.
- Concurrency limits — cap simultaneous in-flight operations rather than the rate; this is the bulkhead pattern.
- Client-side throttling of retries — exponential backoff with jitter is throttling applied to your own outbound traffic, and it is what prevents retry storms.
The conventional contract is to return 429 Too Many Requests with a Retry-After header and RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset headers so well-behaved clients can self-regulate instead of hammering you.
Rate-limiting algorithms
| Algorithm | How it works | Trade-off |
|---|---|---|
| Fixed window | Count requests per clock window; reset at the boundary | Trivial and cheap, but allows a 2x burst across the boundary |
| Sliding window log | Store a timestamp per request and count those inside the window | Exact, but memory grows with request volume |
| Sliding window counter | Weighted blend of the current and previous fixed windows | Near-exact with O(1) memory — the common production choice |
| Token bucket | Tokens refill at a fixed rate; each request spends one | Allows controlled bursts up to bucket size; the usual API-gateway default |
| Leaky bucket | Requests queue and drain at a constant rate | Perfectly smooth output, but adds queuing latency |
-- KEYS[1] = bucket key, ARGV = capacity, refillPerSec, now, cost
local data = redis.call("HMGET", KEYS[1], "tokens", "ts")
local capacity, refill, now, cost =
tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
tokens = math.min(capacity, tokens + (now - ts) * refill)
if tokens < cost then
redis.call("HMSET", KEYS[1], "tokens", tokens, "ts", now)
return 0
end
redis.call("HMSET", KEYS[1], "tokens", tokens - cost, "ts", now)
redis.call("EXPIRE", KEYS[1], 3600)
return 1The atomicity matters: a read-modify-write across separate round trips lets concurrent requests both observe the same token count and both pass. Use a Lua script, a transaction, or an atomic counter primitive.
Common traps
- Dropping the trailing call. Pure leading throttles lose the final event, leaving the UI one step behind reality.
- Using milliseconds where frames are the right unit. Anything visual should use
requestAnimationFrame. - Forgetting
{ passive: true }. Non-passive scroll listeners block scrolling regardless of how well you throttle. - Throttling per instance in a multi-instance backend. An in-memory limiter on five pods means the effective limit is five times what you configured. Rate limits need shared state.
- Synchronised retries. Everyone retrying at exactly the same interval creates a thundering herd. Always add jitter.
- Rate limiting by IP alone. NAT and corporate proxies put thousands of legitimate users behind a single IP; combine with an account or device identity where possible.
How to answer it in an interview
- Define it precisely: "At most one execution per interval, regardless of event volume — it does not wait for silence."
- Contrast with debounce in one sentence so the interviewer knows you understand both.
- Write both implementations — timestamp for simplicity, timer for the trailing call — and explain why
lastArgsexists. - Mention rAF for anything visual. This is the single most differentiating detail in frontend interviews.
- Scale it to the backend: token bucket vs sliding window, shared state in Redis, 429 with Retry-After.
- Name the operational concern: distributed limiters need atomic state, and clients need backoff with jitter.
Summary
Throttling is the "steady cadence" tool. Use it when intermediate events carry real value and you need guaranteed progress under sustained load — scroll position, drag updates, live telemetry, API quotas. On the client, prefer frame-aligned throttling and keep the trailing call. On the server, the same idea becomes rate limiting, where the algorithm choice (token bucket for bursty traffic, sliding window counter for strict fairness) and shared atomic state are what separate a working limiter from a decorative one.