Archtin
All articles
FrontendJavaScriptPerformanceBackendInterview11 min read

Throttling Explained: Rate-Limiting Work Without Dropping It

How throttling works, timer vs timestamp implementations, requestAnimationFrame throttling, server-side rate limiting algorithms, and how to explain throttling in an interview.

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.

One-line definition
Throttle = "run this at most once every N milliseconds, regardless of how many events arrive."

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.

Raw events (e.g. scroll)Throttled (interval = 125ms)firesfiresfiresfiresEvents keep arriving, but execution happens on a steady cadence — at most once per interval.
Throttling admits work on a steady cadence rather than waiting for silence.

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);
    }
  };
}
Leading-edge throttle using a timestamp. Simple, but drops the final event.

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
    }
  };
}
Throttle with both edges — this is what you usually want.

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 });
Frame-aligned throttling — the default choice for scroll and resize handlers.

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

AlgorithmHow it worksTrade-off
Fixed windowCount requests per clock window; reset at the boundaryTrivial and cheap, but allows a 2x burst across the boundary
Sliding window logStore a timestamp per request and count those inside the windowExact, but memory grows with request volume
Sliding window counterWeighted blend of the current and previous fixed windowsNear-exact with O(1) memory — the common production choice
Token bucketTokens refill at a fixed rate; each request spends oneAllows controlled bursts up to bucket size; the usual API-gateway default
Leaky bucketRequests queue and drain at a constant ratePerfectly 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 1
Token bucket in Redis, evaluated atomically with Lua.

The 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

  1. Dropping the trailing call. Pure leading throttles lose the final event, leaving the UI one step behind reality.
  2. Using milliseconds where frames are the right unit. Anything visual should use requestAnimationFrame.
  3. Forgetting { passive: true }. Non-passive scroll listeners block scrolling regardless of how well you throttle.
  4. 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.
  5. Synchronised retries. Everyone retrying at exactly the same interval creates a thundering herd. Always add jitter.
  6. 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

  1. Define it precisely: "At most one execution per interval, regardless of event volume — it does not wait for silence."
  2. Contrast with debounce in one sentence so the interviewer knows you understand both.
  3. Write both implementations — timestamp for simplicity, timer for the trailing call — and explain why lastArgs exists.
  4. Mention rAF for anything visual. This is the single most differentiating detail in frontend interviews.
  5. Scale it to the backend: token bucket vs sliding window, shared state in Redis, 429 with Retry-After.
  6. 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.

Keep reading

Suggested next articles based on this one.

Design it, don't just read it.

Practise LLD and system design problems with structured rubrics and AI feedback.

Start practising free