Filter concepts by levelShowing all levels.

System Design · Rate Limiting

Rate Limiting

Four algorithms, why one instance is not enough, and what to count a limit per

  • Four algorithmsfixed/sliding window, token/leaky bucket
  • Distributedshared state or an edge chokepoint
  • Limit scopeuser, IP, API key, endpoint
  • Combined scopescloses gaps a single key leaves open

1.Fixed window, sliding window, token bucket and leaky bucket

A rate limiter decides, for each incoming request, whether to allow it or reject it based on how many requests the same caller has already made recently. The four standard algorithms differ in how they track "recently" and how they handle bursts: fixed window counts requests in a clock-aligned bucket and resets abruptly, sliding window smooths that count across a moving time range, token bucket allows controlled bursts by spending saved-up tokens, and leaky bucket forces a constant output rate regardless of how bursty the input is.

Key features

  • Fixed window: count requests in a clock-aligned interval (e.g. per-minute); simplest to implement, but the counter resets abruptly at the window boundary
  • The fixed-window boundary-burst problem: because two counters are independent, a caller can send the full limit at the very end of one window and the full limit again at the very start of the next, doubling the effective rate in a short span straddling the boundary
  • Sliding window: evaluates the limit over a continuously moving time range (e.g. "the last 60 seconds from now") rather than a clock-aligned bucket, closing the boundary-burst gap at the cost of more state (a log of timestamps, or a weighted blend of the current and previous fixed windows)
  • Token bucket: a bucket refills with tokens at a fixed rate up to a cap; each request consumes one token, and unused tokens accumulate — so it naturally allows a burst up to the bucket size after an idle period, then throttles to the refill rate
  • Leaky bucket: requests queue into a bucket that drains (is "leaked") at a constant, fixed rate; it smooths bursty arrivals into steady output but can add queuing delay, and requests are dropped if the bucket is full when they arrive

The four algorithms compared

The four algorithms compared
AlgorithmTracksHandles burstsMain weakness
Fixed windowCount per clock-aligned intervalResets abruptlyBoundary burst — up to 2x the limit at window edges
Sliding windowCount over a continuously moving intervalSmooth, no reset spikeMore memory/compute than fixed window
Token bucketTokens refilled at a fixed rate, up to a capAllows a burst up to bucket sizeA long idle period lets a large burst through all at once
Leaky bucketRequests queued, drained at a constant rateSmooths bursts into steady outputAdds queuing latency; excess requests are dropped, not just delayed, once the queue is full
Four rate-limiting algorithms

Fixed window

resets abruptly at the boundary

Sliding window

a continuously moving range

Token bucket

allows a burst up to bucket size

Leaky bucket

constant, fixed drain rate

  1. Fixed window — resets abruptly at the boundary
  2. Sliding window — a continuously moving range
  3. Token bucket — allows a burst up to bucket size
  4. Leaky bucket — constant, fixed drain rate

Command

text
fixed window:    count++ in bucket(now / window_size); reset at boundary
sliding window:   count requests where timestamp > (now - window_size)
token bucket:      tokens = min(cap, tokens + elapsed * refill_rate); allow if tokens >= 1
leaky bucket:      queue.push(request); drain queue at fixed_rate; drop if queue full

Fixed window: simple, but up to ~2x the limit can leak through a window boundary. Sliding window: closes that gap, costs more state. Token bucket: allows bursts up to bucket size after idle time. Leaky bucket: forces a constant output rate, queuing or dropping the rest.

2.Distributed rate limiting needs shared state or edge-level controls

A rate limit is only real if it is enforced against the caller's total traffic across every instance handling requests, not against what one instance happened to see. An in-memory counter that lives inside a single application process only ever sees the slice of traffic a load balancer routed to that one instance — with N instances behind a load balancer, the same caller can get roughly N times the intended limit by having requests spread across them. Fixing this means either giving every instance access to one shared counter (typically Redis) or moving enforcement to a single chokepoint all traffic passes through before it ever reaches an instance (an API gateway or edge/CDN layer).

Key features

  • An in-process counter (a plain variable or in-memory map inside the app) only sees requests routed to that specific instance — it cannot see traffic another instance is handling for the same caller
  • With N stateless instances behind a load balancer, an in-process-only limiter effectively multiplies the intended per-caller limit by up to N
  • Shared-state approach: every instance checks and increments a counter in a fast shared store (typically Redis, using an atomic INCR + EXPIRE or a Lua script) so the count reflects total traffic across all instances
  • Edge-level approach: enforce the limit at a single chokepoint all traffic passes through before reaching any app instance — an API gateway, reverse proxy, or CDN/edge network — so there is only ever one counter, by construction, no cross-instance coordination needed
  • The two approaches are not mutually exclusive: a system might rate-limit coarsely at the edge (cheap, catches obvious abuse early) and enforce a finer per-user or per-endpoint limit with shared state closer to the application

In-process counters vs shared state vs edge-level enforcement

In-process counters vs shared state vs edge-level enforcement
ApproachWhere the count livesCross-instance accurateAdded cost
In-process counterMemory of one app instanceNo — one counter per instanceNone, but limit is not real once scaled out
Shared state (e.g. Redis)One external store, read/written by every instanceYesA network round trip per request; the store becomes a dependency
Edge-level (gateway/CDN)A single chokepoint in front of all instancesYes, by constructionCoarser control over per-application logic; depends on the edge product's own limits
3 instances, 3 counters — 300 allowed, not 100

In-process counters

Instance 1 (100)

Instance 2 (100)

Instance 3 (100)

  • 300 req/min
  • In-process counters — each sees only its own third — each allows 100
    • Instance 1 (100)
    • Instance 2 (100)
    • Instance 3 (100)

Command

text
# in-process (broken once there is more than one instance)
counter = 0  # lives in this process's memory only
if counter >= limit: reject()
counter += 1

# shared-state (correct across instances) — see the Redis
# INCR + EXPIRE pattern already covered under Redis for
# distributed caching, rather than re-deriving it here

An in-process counter is only correct with exactly one instance — scale to N instances and it silently allows up to N times the intended limit. Fix it with shared state (e.g. Redis) that every instance reads and writes, or by enforcing the limit at a single edge/gateway chokepoint before requests ever reach an instance.

3.Choosing the right limit scope: user, IP, API key, endpoint

A rate limit is defined by two things: a number, and what that number is counted per — the "key." The same numeric limit means something very different depending on whether it is counted per user account, per IP address, per API key, or per endpoint (or some combination of these). Picking the wrong key either fails to stop the abuse it was meant to prevent, or throttles legitimate traffic that happens to share the key with someone else.

Key features

  • Per-user (or per-account): the most precise scope for authenticated abuse — one user cannot exceed their limit regardless of how many IPs or devices they use — but requires the caller to already be authenticated before the limit can apply
  • Per-IP: works for unauthenticated endpoints (login, signup, password reset) where there is no user identity yet, but a shared IP (corporate NAT, mobile carrier NAT, public Wi-Fi) means many legitimate users can trip one IP-based limit together, and a distributed attacker rotating IPs evades it entirely
  • Per-API-key: the standard scope for third-party/programmatic access — ties the limit to a specific integration or client application rather than a human, and keys can carry different limits (tiered plans) unlike a blanket per-user limit
  • Per-endpoint: the same caller can have different limits on different routes — a cheap read endpoint tolerates a high limit, while an expensive write, search, or password-reset-email endpoint gets a much tighter one, independent of who is calling it
  • Real systems typically combine scopes rather than picking one: e.g. a login endpoint might limit by IP (catch a single-source brute force) AND by account username (catch credential stuffing spread across many IPs targeting one account)

Limit scope matched to the abuse scenario it actually defends against

Limit scope matched to the abuse scenario it actually defends against
ScopeDefends well againstWeak point
Per-user / per-accountOne authenticated user overusing the API from any deviceRequires authentication to already exist; useless for pre-login abuse
Per-IPA single-source flood or scraper hitting unauthenticated endpointsShared NAT punishes many legitimate users together; easily evaded by rotating IPs
Per-API-keyRunaway or misbehaving third-party integrations; enforcing tiered plansA leaked key still gets the legitimate holder's full limit until it is revoked
Per-endpointProtecting an expensive operation (search, export, password-reset email) independent of caller identityDoes not by itself stop one caller from abusing many different endpoints
Four limit scopes, four abuse shapes

Per-user

needs auth already established

Per-IP

weak against shared NAT / rotation

Per-API-key

ties to one integration

Per-endpoint

protects one expensive route

  1. Per-user — needs auth already established
  2. Per-IP — weak against shared NAT / rotation
  3. Per-API-key — ties to one integration
  4. Per-endpoint — protects one expensive route

Command

text
# a limit is a (key, count, window) triple — the key decides the scope
key = user_id                     # per-user
key = client_ip                   # per-IP
key = api_key                     # per-API-key
key = f"{api_key}:{endpoint}"     # per-key, per-endpoint (combined scope)
key = f"{client_ip}:{username}"   # per-IP AND per-account (login example)

A limit is a (key, count, window) triple — the key is the scope. Per-user/API-key for precise, identity-based control; per-IP for pre-authentication traffic (with shared-NAT and rotation caveats); per-endpoint to protect specific expensive operations regardless of caller. Combine scopes (e.g. IP + account on login) when a single scope leaves a gap an attacker can spread across.

Remember

  • Fixed window is simplest but can leak up to ~2x the limit across a window boundary.
  • Token bucket allows bursts up to bucket size; leaky bucket forces a constant output rate.
  • An in-process counter is only correct at one instance — scale out and it silently multiplies the effective limit.
  • Combine scopes (e.g. IP + account on login) when a single scope leaves a gap an attacker can spread across.

Pro tip

  • Reach for sliding window or token bucket over fixed window whenever a true worst-case burst cap matters.
  • Decide fail-open vs fail-closed for the shared rate-limit store explicitly — do not let a client library's default decide it by accident.

Next up

  • Quotas and fairness
  • Real-time systems
  • WebSockets at scale

Sources

  • Redis rate-limiting glossary — redis.io/glossary/rate-limiting
  • Checked 2026-08-22

Spotted a mistake on this page? Tell us.