BackendMedium35 min

Design a Rate Limiter

Asked both as an algorithm question and as a distributed system question; be ready for either.

Say it in 30 seconds
  • 10K req/speak traffic, one script call each
  • ~200 MBmemory for 1M active keys
  • < 5 msadded p99 latency target
Show what to say

“I'd use a token bucket per user, API key or IP, stored in Redis and updated by one Lua script on Redis's own clock, so the check is atomic across every app server. The gateway answers 429 with Retry-After. If Redis dies, most endpoints fail open to a local bucket, but login and OTP fail closed.”

Trade-offs

  • Central Redis vs local only: accurate global limits, but you add a network hop and a dependency.
  • Token bucket vs sliding window: bursts and tiny state, but a looser 'per minute' limit.
  • Fail open vs closed: open keeps the API available, but allows abuse during outages.

Bring it up before they ask

If Redis is down or slow, fail fast with a 50 ms timeout, then fall back per rule: a local bucket for normal endpoints, a 503 for login and payments.

New to the words? Read the one-minute primer

0 of 0 sections ticked
30-second answer

Give each user a in , updated by one Lua script so the check is atomic. Over the limit, the gateway answers 429. If Redis dies, most endpoints keep working and login does not.

Functional

  • Limit per user, API key or IP, e.g. 100 requests per minute
  • Different rules per endpoint and plan
  • 429 with Retry-After and the remaining quota

Non-functional

  • Under 5 ms added p99
  • Consistent across N app servers
  • A limiter outage must not take the API down
  • Memory-bounded for millions of keys

Out of scope

  • Billing for overages
  • Volumetric DDoS (the CDN/WAF does that)

Two questions in one: which algorithm, and how to make it hold across 50 servers.

Scope and scale

Read more · 86 words
  • Limit the user, the key or the IP?
  • Bursts allowed, or smooth traffic?
  • Reject, or just warn?
  • One region or global?
  • If the limiter fails, serve or block?

Size it:

One handles this. Split by key with Redis Cluster when it can't.

Peak traffic
10K req/s
one script call per request
One Redis primary
~100K ops/s
scripts cost more, but 10K/s fits
1M active keys
~200 MB
two small fields each; idle keys expire

Choosing an algorithm

Five algorithms, one real question: how much burst do you allow?

Read more · 150 words
AlgorithmState per keyBehaviour
Fixed window1 counterSimplest; 2× bursts at the edges
Sliding logEvery timestampExact; memory grows
Sliding window counter2 countersNearly exact; good for quotas
Token bucketTokens + timeAllows bursts; the API default
Leaky bucketQueueSmooth; adds delay

Match the traffic to the algorithm:

TrafficPickWhy
A page fires 20 calls at onceToken bucketThe burst passes, the average stays 5 a second
Paid quota of 100 a minuteSliding window counterNo edge doubling
Calls to a fragile partnerLeaky bucketSteady rate out

A bucket refills at a steady rate. Each request spends a token. Bursts pass, the average holds. Fire a burst at it.

A fixed window resets on the minute. At 100 a minute, a client sends 100 at 11:59:59 and 100 at 12:00:00: 200 in two seconds. Try it here.

A sliding window is a frame around a few cells that shifts one step along the row.
Interactive

Token bucket

The bucket refills at rate r up to capacity b. Each request takes one token; an empty bucket answers 429 with a computed Retry-After.

refill +2/s10.0of 10 tokenslimiterClientAPI
Tokens
10.0 / 10 (100%)
Allowed (200)
0
Rejected (429)
0
Last Retry-After
–

The bucket starts full: up to 10 requests can burst, then 2 per second. Totals: 0 allowed, 0 rejected.

Interactive

Fixed window vs sliding window

Limit: 10 requests per 10 s. Fire bursts right before and right after the 10 s boundary and compare what each limiter lets through.

window 0–10 swindow 10–20 sFixed window1 counterlimit 108.0 s: allowed8.2 s: allowed8.4 s: allowed8.6 s: allowed8.8 s: allowed9.0 s: allowed9.2 s: allowed9.4 s: allowed9.6 s: allowed9.8 s: allowed10.0 s: allowed10.2 s: allowed10.4 s: allowed10.6 s: allowed10.8 s: allowed11.0 s: allowed11.2 s: allowed11.4 s: allowed11.6 s: allowed11.8 s: allowedcounter = 10Sliding window2 counterslimit 108.0 s: allowed8.2 s: allowed8.4 s: allowed8.6 s: allowed8.8 s: allowed9.0 s: allowed9.2 s: allowed9.4 s: allowed9.6 s: allowed9.8 s: allowed10.0 s: rejected (429)10.2 s: allowed10.4 s: rejected (429)10.6 s: rejected (429)10.8 s: rejected (429)11.0 s: rejected (429)11.2 s: allowed11.4 s: rejected (429)11.6 s: rejected (429)11.8 s: rejected (429)estimate = 10 × 0.60 + 2 = 8.0boundary6 s7 s8 s9 s10 s11 s12 s13 s14 s
  • allowed
  • rejected (429)
  • counter the limiter compares with 10

Max allowed in any 10 s span: fixed 20, sliding 12. Fixed window allowed 20 requests within 4 s (2.0× the limit of 10 per 10 s, because its counter resets at the 10 s boundary). Sliding-window counter allowed 12: just after the boundary the previous window still counts at almost full weight.

API and data model

The contract is mostly headers. Here is a 429.

Read more · 115 words
HeaderWhenMeaning
RateLimit-LimitEvery responseBucket capacity (the burst size)
RateLimit-RemainingEvery responseWhole tokens left right now
RateLimit-ResetOptionalSeconds until the bucket is full again
Retry-AfterOn 429 (and 503)Seconds to wait, from the token deficit

The gateway calls check(key, rate, capacity) and gets { ok, remaining, retryAfterMs }.

Keys in :

KeyHoldsExpiry
rl:<rule>:<id>Hash: tokens (float), ts (ms, Redis time)Time to refill completely
sw:<key>:<window>String: request count in that windowTwo windows

Rules are cached config. plans scales them per tier.

HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 20
RateLimit-Remaining: 0
Retry-After: 1
Content-Type: application/json

{"error":"Too many requests"}
{
  "api":   { "ratePerSec": 5,   "capacity": 20, "key": "user", "failClosed": false },
  "login": { "ratePerSec": 0.1, "capacity": 5,  "key": "ip",   "failClosed": true },
  "plans": { "free": 1, "pro": 10 }
}

Architecture

One server can count in memory.

Read more · 55 words

Behind 50, each counting alone, a user capped at 100 a minute could send 5,000. Shared state fixes that.

Play Allowed, Burst exhausted (a 429) and down (the local fallback).

A circuit breaker is a fuse box that flips its switch off when too much current surges in, cutting the wire to protect what is behind it.

Press Play to watch it run · 6 steps

Every gateway instance runs the same Lua token-bucket script against one shared Redis, so the limit holds across all of them. Rejected requests get a 429 and never reach the service.

Rate limiter at the gatewayEvery gateway instance runs the same Lua token-bucket script against one shared Redis, so the limit holds across all of them. Rejected requests get a 429 and never reach the service.request / 429EVALSHAfallbackallowed onlyClientsExpress middleware or an Envoy/NGINX filter: one check per request.API gateway×N, runs limiterHash rl:<rule>:<id> with tokens and ts, expired when idle.Redis primaryLua token bucketRedis replicapromoted on failureOnly used while Redis is unreachable.Local bucketsper instance LRUService×3

Tap any box to see what it is and what it talks to.

  • Client
  • API gateway
  • Cache
  • Service
Open in playground
Text description
  1. GET /api/orders arrives with API key k_42. The rule for this plan is 5 tokens per second, bursts up to 20.
  2. The gateway runs the token-bucket script on rl:api:k_42. Read, refill, take and write happen in one atomic step.
  3. Redis replies [1, 19, 0]: allowed, 19 tokens left, no wait. The whole check took under a millisecond.
  4. The request is forwarded to the service.
  5. The service answers.
  6. 200 OK, with RateLimit-Limit: 20 and RateLimit-Remaining: 19 so the client can pace itself.

Step-by-step answer

In one line each:

Read more · 258 words
  1. Clarify

    Who, how hard.

  2. Pick an algorithm

    Bursts or strictness.

  3. Place the state

    Shared .

  4. Make it atomic

    One Lua script.

  5. Failure and UX

    Open or closed.

In full:

  1. Identity

    The user or key first. The IP only for anonymous traffic.

  2. Algorithm

    A by default. A sliding window counter for strict "100 per minute" quotas.

  3. Where to enforce

    Global limits at the gateway, where rejecting is cheap.

  4. State

    One hash per key, expiring once it is full.

  5. Atomicity

    One Lua script reads, refills, takes and writes. A separate GET and SET would race: two servers read "1 token left" and both spend it.

    tokens = math.min(cap, tokens + math.max(0, now - ts) * rate / 1000)
    if tokens >= 1 then tokens = tokens - 1; allowed = 1 end
    redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
  6. Clock

    Use Redis's own TIME, so a fast server clock can't mint tokens.

  7. Response

    429 with Retry-After, plus quota headers on every response.

  8. Failure policy

    • Normal endpoints fail open, to a local bucket.
    • Login, OTP and payments fail closed (503).
    • Fail fast: a 50 ms timeout, no retries, or the limiter becomes the outage.
  9. Rules and regions

    Rules per plan come from cached config.

  10. Observability

    Count allowed and limited calls. Alert on 429 spikes.

Implementation

Read more · 59 words
  • The in-memory TokenBucket is the single-server answer and the fallback.
  • The Lua script uses 's clock and works out the retry delay.
  • middleware.js keeps fallback buckets in the bounded LRU from the caching lesson.
// In-memory token bucket: a single instance, and the fallback when Redis is down
export class TokenBucket {
  constructor({ ratePerSec, capacity, now = () => Date.now() }) {
    Object.assign(this, { ratePerSec, capacity, now, tokens: capacity, last: now() });
  }
  take(n = 1) {
    const t = this.now();
    this.tokens = Math.min(this.capacity, this.tokens + ((t - this.last) / 1000) * this.ratePerSec);
    this.last = t;
    if (this.tokens >= n) {
      this.tokens -= n;
      return { ok: true, remaining: Math.floor(this.tokens), retryAfterMs: 0 };
    }
    return { ok: false, remaining: 0, retryAfterMs: Math.ceil(((n - this.tokens) / this.ratePerSec) * 1000) };
  }
}

Failure modes

Read more · 56 words
What breaksEffectMitigation
Redis downNo shared stateFail fast; local bucket (open) or 503 (closed)
Redis slowEvery request slows50 ms timeout, then treat as down
One key floodsA hot Redis shardA local pre-limiter
Skewed clocksWrong refillsRedis TIME in the script
Millions of idle keysMemory growthExpire them

Trade-offs

Read more · 111 words
ChoiceProsCons
Central Redis vs localAccurate global limitsA hop and a dependency
Token bucket vs sliding windowBursts, tiny stateLooser "per minute"
Fail open vs closedStays upAbuse during outages
Gateway vs in-serviceRejects earlyBlind to cost

Follow-up questions

Per-IP or per-user limits, and what about shared NATs?

By user or key. Per-IP only for anonymous traffic, and generous. For login, use both.

How do you handle 1M distinct keys?

Expiry drops idle buckets. Cluster scales it, since each script touches one key.

How do you avoid clock skew between servers?

Read the time from Redis inside the script.

How do you rate-limit globally across regions?

Give each region a share, or sync counters later and accept a brief overshoot.

Rate limiting or load shedding?

Both. is fair per client. drops low-priority work when overloaded.

Check yourself

Question 1 of 3

Score 2 out of 3 or better to mark this page complete.

1. Why run the token bucket as one Lua script instead of GET, then SET?

Patterns behind this

The coding patterns this design is built on. Practise them and the design questions get easier.