Skip to content
Commitwise
Menu
System Design

Design a rate limiter

Design a rate limiter step by step, from requirements and algorithms (fixed window, sliding window, token bucket) to Redis counters, HTTP 429 responses and failure modes, with code in Swift, Kotlin, Java and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A bot tries 10,000 passwords a minute on your login endpoint. A partner’s buggy script calls your API in a tight loop and slows it down for everyone. A free-tier user runs up a huge bill for an AI feature. All three need the same thing: a rate limiter that decides, for every request, “allowed” or “too many, try later”.

“Design a rate limiter” is a favorite interview question because it’s small, concrete, and touches algorithms, distributed state, HTTP and failure handling in one go.

New to some of these? Open what you need, or skip ahead.

Basics:What are clients and servers?

A server is a computer (usually many of them) that runs your backend: it stores data and answers requests. A client is whatever sends the requests: your mobile app, a website, another server.

Big systems put many identical servers behind one address, with a load balancer in front that spreads requests between them. Data lives in a database, and frequently used data is often kept in a fast cache (in memory) so the database isn’t asked every time.

Basics:How do HTTP requests work?

Apps and servers talk with HTTP. A request has a method that says what to do (GET to read, POST to create, PUT to replace, DELETE to remove), a URL (https://api.example.com/books/42), headers (extra information, like who you are) and sometimes a body (the data, usually JSON).

The server sends back a response with a status code that says how it went (200 OK, 201 created, 404 not found, 500 server error), headers, and usually a body. Codes starting with 4 mean the client asked for something wrong; codes starting with 5 mean the server failed.

Basics:What is a cache?

A cache keeps a copy of data somewhere faster to reach than its original source. Instead of asking the database for the same popular product 1,000 times a second, the server keeps it in memory (or in a cache server like Redis) and answers from there.

Two questions come with every cache: how long may a copy be used before it might be out of date (its time to live, or TTL), and what happens when the original changes (the copy must be removed or updated).

Basics:What is an API?

An API (application programming interface) is the set of operations a system offers to other programs. In app development it usually means a web API: a server with URLs your app calls to read and change data, like GET /api/products to list products.

The API is a contract: the app relies on its URLs, fields and status codes. Changing them carelessly breaks apps that are already installed on people’s phones.

Part Not started

Step 1: Ask before you draw

Rate limiting means “at most N requests per time period, per someone”. Before choosing anything, find out:

  • Who is limited? A user, an IP address, an API key, a whole tenant? (Bots can change IPs; users can share one office IP.)
  • What is limited? Every request, or only some endpoints (login, search, sending messages)?
  • How strict? Is “about 100 per minute” fine, or must it be exact? May short bursts go through?
  • Where does it run? In each app server, in an API gateway in front of them, or both?
  • What does the client see when it’s limited, and what if the limiter itself breaks?

Clarify the identity (user, API key, IP, tenant, or combinations, since limits are often layered), the granularity (global, per endpoint, per cost unit), accuracy vs cost (exact sliding windows vs approximations), burst tolerance, scale (requests per second, number of identities, regions), latency budget (the limiter is on every request’s path, so single-digit milliseconds), and the failure policy (fail open or closed per endpoint). State the interface early: allow(key, cost) → (allowed, remaining, retryAfter).

For this design: an API with 10 million users, limits per user per endpoint (for example 100 requests per minute, 5 login attempts per minute), short bursts allowed, enforced across many API servers.

Step 2: Pick an algorithm

AlgorithmHow it worksGoodWatch out
Fixed window countercount requests per user per calendar minutetiny state, simpleup to 2× the limit around a window boundary
Sliding window logstore a timestamp per request; count those in the last 60 sexactmemory grows with the limit (100 timestamps per user)
Sliding window counterweighted mix of the previous and current window’s countsclose to exact, 2 numbers per useran approximation
Token bucketa bucket of N tokens, refilled at a steady rate; each request takes oneallows bursts up to N, then a steady ratetwo numbers per user; refill math must be right
Leaky bucketrequests queue and leave at a fixed ratesmooths traffic for downstreamadds latency; queue can fill

The token bucketToken bucketA rate limiting algorithm that allows bursts up to a bucket size and then a steady rate of requests.Open in glossary → is the most common answer, and the easiest to picture: a bucket holds up to 10 tokens. Every request takes one token; if the bucket is empty, the request is rejected. Tokens drip back in at a steady rate, say one every 6 seconds. A user who was quiet can burst 10 requests at once, and after that gets about 10 per minute.

actor TokenBucket {
    private let capacity: Double                   // the biggest burst
    private let refillPerSecond: Double            // the steady rate
    private var tokens: Double
    private var lastRefill = ContinuousClock.now

    init(capacity: Double, refillPerSecond: Double) {
        self.capacity = capacity
        self.refillPerSecond = refillPerSecond
        self.tokens = capacity
    }

    func tryAcquire(cost: Double = 1) -> Bool {
        let now = ContinuousClock.now
        let elapsed = lastRefill.duration(to: now)
        let seconds = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
        tokens = min(capacity, tokens + seconds * refillPerSecond)   // refill for the time that passed
        lastRefill = now
        guard tokens >= cost else { return false }
        tokens -= cost
        return true
    }
}
class TokenBucket(
    private val capacity: Double,                  // the biggest burst
    private val refillPerSecond: Double,           // the steady rate
    private val nanoTime: () -> Long = System::nanoTime,
) {
    private var tokens = capacity
    private var lastRefill = nanoTime()

    @Synchronized
    fun tryAcquire(cost: Double = 1.0): Boolean {
        val now = nanoTime()
        val elapsedSeconds = (now - lastRefill) / 1_000_000_000.0
        tokens = minOf(capacity, tokens + elapsedSeconds * refillPerSecond)   // refill for the time that passed
        lastRefill = now
        if (tokens < cost) return false
        tokens -= cost
        return true
    }
}
public final class TokenBucket {
    private final double capacity;                 // the biggest burst
    private final double refillPerSecond;          // the steady rate
    private double tokens;
    private long lastRefill = System.nanoTime();

    public TokenBucket(double capacity, double refillPerSecond) {
        this.capacity = capacity;
        this.refillPerSecond = refillPerSecond;
        this.tokens = capacity;
    }

    public synchronized boolean tryAcquire(double cost) {
        long now = System.nanoTime();
        double elapsedSeconds = (now - lastRefill) / 1_000_000_000.0;
        tokens = Math.min(capacity, tokens + elapsedSeconds * refillPerSecond);   // refill for the time that passed
        lastRefill = now;
        if (tokens < cost) return false;
        tokens -= cost;
        return true;
    }
}
class TokenBucket {
  private tokens: number;
  private lastRefill = performance.now();          // monotonic, in milliseconds

  constructor(
    private readonly capacity: number,             // the biggest burst
    private readonly refillPerSecond: number,      // the steady rate
  ) {
    this.tokens = capacity;
  }

  tryAcquire(cost = 1): boolean {                  // one thread per process: no lock needed
    const now = performance.now();
    const elapsedSeconds = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(this.capacity, this.tokens + elapsedSeconds * this.refillPerSecond);   // refill for the time that passed
    this.lastRefill = now;
    if (this.tokens < cost) return false;
    this.tokens -= cost;
    return true;
  }
}

There’s no background timer refilling buckets. Each request calculates how many tokens would have dripped in since the last request, which is why a bucket only needs two numbers: the token count and the time of the last refill. It uses a monotonic clock (nanoTime, ContinuousClock, performance.now()), which never jumps back when the device’s wall clock changes.

The boundary problem

The simplest algorithm, a counter per calendar minute, has a weakness you should mention in the interview:

Predict the output

The limit is 100 requests per minute, with a fixed window per calendar minute. A client sends 100 requests at 10:00:59 and 100 more at 10:01:00. How many are allowed?

The sliding window estimate

Predict the output

Limit: 100 requests per minute, sliding window counter. The previous minute had 80 requests; the current minute has had 30 so far, and we're 15 seconds into it. Is the next request allowed?

  • Requirements: identity (user, key, IP, tenant), scope (global, endpoint, cost), accuracy, burst tolerance, latency budget (≤ 1–2 ms), failure policy. Interface: allow(key, cost) → (allowed, remaining, retryAfter).
  • Algorithms: fixed window (2× boundary bursts), sliding log (exact, memory-heavy), sliding window counter (2 counters, good approximation), token bucket (bursts + steady rate, most common), leaky bucket (smoothing).
fun allowed(previousCount: Long, currentCount: Long, elapsedInWindow: Duration, window: Duration, limit: Long): Boolean {
    val previousWeight = 1.0 - elapsedInWindow / window             // share of the previous window still in range
    val estimate = previousCount * previousWeight + currentCount
    return estimate < limit
}
static boolean allowed(long previousCount, long currentCount, long elapsedMs, long windowMs, long limit) {
    double previousWeight = 1.0 - (double) elapsedMs / windowMs;    // share of the previous window still in range
    double estimate = previousCount * previousWeight + currentCount;
    return estimate < limit;
}
function allowed(previousCount: number, currentCount: number, elapsedMs: number, windowMs: number, limit: number): boolean {
  const previousWeight = 1 - elapsedMs / windowMs;               // share of the previous window still in range
  const estimate = previousCount * previousWeight + currentCount;
  return estimate < limit;
}

Check this part

  1. How does a token bucket allow short bursts?

  2. What is the weakness of a fixed window counter per calendar minute?

  3. Sliding window counter, limit 100/min: the previous minute had 80, the current one 30 so far, 15 seconds in. Is the next request allowed?

  4. Why does the bucket measure time with ContinuousClock instead of Date()?Why does the bucket measure time with System.nanoTime() instead of currentTimeMillis()?Why does the bucket measure time with performance.now() instead of Date.now()?

Ask who is limited, on what, how strictly and where. Then pick an algorithm: fixed windows allow 2× bursts at the boundary, the token bucket allows a burst up to its capacity and a steady rate after, computed on each request from two numbers.

Part Not started

Step 3: Where it runs

Rate limiting in front of the API

HTTPScheck + countlimitsallowedClientsapps, partners, botsAPI gatewayrate limiterCounter storeRedisRulesconfigAPI servicesDatabase

Tap a box to see what it does.

Click the boxes for details. The key idea: with many gateway or API servers, each one keeping its own buckets in memory would let a user get the limit once per server. The counters must be shared, which is why they live in Redis: fast (well under a millisecond per check) and with keys that expire on their own.

Practice: following one request

In the interview, walk one request through your design:

  1. Identify the caller: the gateway reads the API key or the user id from the access token (or uses the IP for anonymous traffic).
  2. Find the rule: POST /login → 5 per minute per account; everything else for a free user → 100 per minute.
  3. Check and count atomically in Redis for the key rl:{rule}:{user}: one round trip, under a millisecond.
  4. Allowed: forward to the service and add RateLimit-Remaining to the response.
  5. Limited: answer 429 with Retry-After straight from the gateway; the service never sees the request.
  6. Redis is down or slow: apply the endpoint’s failure policy (most endpoints let the request through, login doesn’t), and alert.

Distributed counters

Shared counters must be updated atomically: two gateways reading count = 99 and both writing 100 lets both requests through. In Redis, run the whole check-and-update as one Lua script (Redis executes scripts atomically), with expiry set in the same step. Per-request cost: one round trip and a few microseconds of Redis CPU; a single Redis primary handles on the order of 100,000 such scripts per second, and a cluster scales by sharding on the key.

Sliding window counter is the usual production compromise: keep counts for the current and previous fixed windows, and estimate the rolling count as previous × (1 − elapsed fraction of current window) + current. Two counters per key, error typically well under the limit’s granularity.

A counter that never expires

Spot the bugSome users are blocked forever

Tap the line with the bug.

fun allow(userId: String): Boolean {
    val key = "rl:api:" + userId
    val count = redis.opsForValue().increment(key)!!
    if (count == 1L) redis.expire(key, Duration.ofMinutes(1))
    return count <= 100
}
boolean allow(String userId) {
    String key = "rl:api:" + userId;
    long count = redis.opsForValue().increment(key);
    if (count == 1L) redis.expire(key, Duration.ofMinutes(1));
    return count <= 100;
}
async function allow(userId: string): Promise<boolean> {
  const key = "rl:api:" + userId;
  const count = await redis.incr(key);
  if (count === 1) await redis.expire(key, 60);
  return count <= 100;
}

Accuracy, latency and scale trade-offs

  • Local + global: each gateway keeps a local token bucket and syncs with Redis every few hundred milliseconds (or takes tokens from Redis in batches). Far fewer Redis calls, at the cost of briefly exceeding the limit by up to (instances × batch).
  • Multiple regions: a global exact limit needs cross-region coordination on every request (too slow). Use per-region limits (global limit ÷ regions, rebalanced periodically), or accept eventual consistency with asynchronous counter replication.
  • Failure policy: fail open for most endpoints (a broken limiter shouldn’t take down the API), fail closed for abuse-sensitive ones (login, SMS sending, payments), with short timeouts either way.
  • Fairness and cost: weight requests by cost (a search or export counts more than a read), and keep separate limits per tier and per endpoint, so one heavy endpoint can’t consume a user’s whole budget.
  • Load shedding is different: rate limits enforce per-client fairness; when the system itself is overloaded, shed by priority (drop analytics before checkout) regardless of who’s within their limit.

Deep dives

Deep diveWhy a Lua script is atomic, and what it costsInterview favorite

Redis executes commands on a single thread, so a Lua script (EVAL, or Redis Functions) runs from start to finish with nothing else in between. That’s what makes “read, compute, write, set expiry” one atomic step without locks.

The price is the same property: while a script runs, every other client waits. Keep scripts short and bounded (no loops over large collections), pass the current time as an argument instead of relying on server clocks, and in Redis Cluster make sure every key a script touches is in the same hash slot, which is what hash tags like rl:{user42}:api are for.

  • Storage: shared counters in Redis, updated atomically with Lua (INCR + EXPIRE in one script), keys with TTLs, hash tags for cluster sharding.
  • Scale: local buckets with batched token leases for hot keys; per-region shares of global limits instead of cross-region calls.
  • Failure: short timeouts; fail open (most endpoints) or closed (login, SMS, payments); alert. Rate limits are fairness; load shedding by priority is a separate tool.

Check this part

  1. Why do the counters live in Redis instead of each server's memory?

  2. Why reject limited requests at the gateway, before the services?

  3. Why run the counter update as a Lua script in Redis?

  4. How do you avoid a Redis call per request for a customer allowed 20,000 requests per minute?

With many servers, each keeping its own counters would give a user the limit once per server, so counters live in Redis, updated atomically (check, increment and expiry in one Lua script). At high rates, gateways lease tokens in batches.

Part Not started

Step 4: What the client sees

HTTP/1.1 429 Too Many Requests
Retry-After: 12
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 12
Content-Type: application/problem+json

{ "type": "/errors/rate-limited", "title": "Too many requests", "detail": "Try again in 12 seconds." }

429 tells the client it did nothing wrong except go too fast, and Retry-After says when to try again. Mobile apps should honor it (and back off with jitter) instead of retrying immediately, which only makes the limit last longer.

A bucket that never refills

Spot the bugUsers locked out after one burst

Tap the line with the bug.

fun tryAcquire(): Boolean {
    val now = System.currentTimeMillis()
    tokens = minOf(capacity, tokens + (now - lastRefill) / 1000 * refillPerSecond)
    lastRefill = now
    if (tokens < 1) return false
    tokens -= 1
    return true
}
// tokens, capacity and refillPerSecond are Long
boolean tryAcquire() {
    long now = System.currentTimeMillis();
    tokens = Math.min(capacity, tokens + (now - lastRefill) / 1000 * refillPerSecond);
    lastRefill = now;
    if (tokens < 1) return false;
    tokens -= 1;
    return true;
}
// tokens, capacity and refillPerSecond are long
tryAcquire(): boolean {
  const now = Date.now();
  this.tokens = Math.min(this.capacity, this.tokens + Math.floor((now - this.lastRefill) / 1000) * this.refillPerSecond);
  this.lastRefill = now;
  if (this.tokens < 1) return false;
  this.tokens -= 1;
  return true;
}
// Math.floor keeps the token count a whole number

Exerciseeasy

Protect the login endpoint

Attackers try leaked email/password pairs against POST /login: some try many passwords for one account from many IPs, others try one password for many accounts from a few IPs. Real users sometimes mistype their password a few times.

Design the rate limits: which keys, which limits, and what the user experiences.

›

One key isn’t enough. Limiting by IP alone misses attacks spread over many IPs; limiting by account alone lets one IP try every account.

›

Think about two limits that both must pass, and about what happens to a real user after too many failures: an error forever is bad, and so is unlimited guessing.

SolutionTry it yourself first

Two limits, both checked on every attempt, counting failed logins:

KeyLimitStops
login:account:{email}5 failures per 15 minutes, then exponential delaypassword guessing on one account, from any number of IPs
login:ip:{ip}50 failures per 15 minutesone IP trying many accounts (credential stuffing)
fun login(email: String, password: String, ip: String): LoginResult {
    val accountKey = "login:account:${email.lowercase()}"
    val ipKey = "login:ip:$ip"

    if (limiter.isBlocked(accountKey, max = 5, window = 15.minutes) ||
        limiter.isBlocked(ipKey, max = 50, window = 15.minutes)
    ) {
        return LoginResult.TooManyAttempts(retryAfter = limiter.retryAfter(accountKey, ipKey))
    }

    val user = users.verify(email, password)
    if (user == null) {
        limiter.recordFailure(accountKey)
        limiter.recordFailure(ipKey)
        return LoginResult.InvalidCredentials        // same message whether the email exists or not
    }
    limiter.reset(accountKey)                        // a successful login clears the account counter
    return LoginResult.Success(user)
}
async function login(email: string, password: string, ip: string): Promise<LoginResult> {
  const accountKey = "login:account:" + email.toLowerCase();
  const ipKey = "login:ip:" + ip;
  const window = { max: 5, windowMs: 15 * 60_000 };

  if ((await limiter.isBlocked(accountKey, window)) ||
      (await limiter.isBlocked(ipKey, { ...window, max: 50 }))) {
    return { kind: "tooManyAttempts", retryAfter: await limiter.retryAfter(accountKey, ipKey) };
  }

  const user = await users.verify(email, password);
  if (user === null) {
    await limiter.recordFailure(accountKey);
    await limiter.recordFailure(ipKey);
    return { kind: "invalidCredentials" };       // same message whether the email exists or not
  }
  await limiter.reset(accountKey);               // a successful login clears the account counter
  return { kind: "success", user };
}

What the user experiences: after 5 wrong passwords, “Too many attempts, try again in 15 minutes” (or a growing delay), plus an email “someone tried to sign in to your account” and a password-reset link, so a locked-out real user has a way forward. The response never reveals whether an email exists. Normalize the email (lowercase) before using it as a key, or attackers bypass the limit with Ana@ vs ana@. Beyond rate limits, add a CAPTCHA or proof-of-work after a few failures, and check passwords against known breached lists.

Exercisehard

Rate limiting a public API at scale

A public API serves 10 million users and partners through 60 gateway instances in 3 regions, at a peak of 50,000 requests per second. Plans: free (60 requests/min), pro (1,000/min), enterprise (custom, up to 20,000/min). Exports and AI endpoints are 50× more expensive than normal requests. The limiter may add at most 2 ms at p99.

Design the rate limiting: algorithm, storage, keys, multi-region behavior, failure modes and what clients see.

›

At 50,000 requests per second, one Redis round trip per request is possible but not free, and a single Redis can become the bottleneck and a single point of failure. Where can you trade exactness for speed?

›

Enterprise customers with 20,000/min are spread over all gateways; free users with 60/min mostly hit one or two. Do they need the same mechanism?

SolutionTry it yourself first
/** Token bucket in Redis: tokens and last refill in one hash, updated atomically. */
private val tokenBucket = RedisScript.of<List<Long>>(
    """
    local capacity = tonumber(ARGV[1])
    local refill_per_ms = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])
    local cost = tonumber(ARGV[4])
    local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
    local tokens = tonumber(b[1]) or capacity
    local ts = tonumber(b[2]) or now
    tokens = math.min(capacity, tokens + math.max(0, now - ts) * refill_per_ms)
    local allowed = 0
    if tokens >= cost then
        tokens = tokens - cost
        allowed = 1
    end
    redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
    redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / refill_per_ms) * 2)
    local retry_ms = allowed == 1 and 0 or math.ceil((cost - tokens) / refill_per_ms)
    return { allowed, math.floor(tokens), retry_ms }
    """.trimIndent(),
    List::class.java as Class<List<Long>>,
)

The design:

DecisionChoiceWhy
Algorithmtoken bucket per (plan rule, key), with cost per endpoint (export = 50)bursts up to the bucket size, steady rate after; expensive calls spend more tokens
KeysAPI key for partners, user id for apps, IP only for anonymous traffic; hash tag {key}identifies the payer; keeps all of one key’s buckets on one Redis shard
StorageRedis Cluster per region, Lua script per check (time passed in by the gateway, using its synchronized clock)atomic, under 1 ms, scales by sharding on the key
Hot, high-limit keysgateways take tokens from Redis in batches (e.g. 100 at a time) and spend them locallyenterprise keys at 20,000/min don’t turn into 20,000 Redis calls per minute per gateway
Regionseach region enforces a share of the global limit (traffic-weighted), rebalanced every few seconds from a global aggregateexact global limits would need cross-region calls on every request
FailureRedis timeout 5 ms; on error fail open with a local per-instance bucket (limit ÷ instances), fail closed for login and SMSthe API stays up, abuse-sensitive endpoints stay protected
Client contract429 + Retry-After + RateLimit-* headers, documented limits per plan, SDKs back off with jitterwell-behaved clients slow down before being cut off
Observabilityrejections per rule and plan, Redis latency, keys near their limitcatch misconfigured limits before customers do

Numbers to say out loud: 50,000 checks per second spread over a Redis cluster of a few shards is comfortable (each handles ~100k simple scripts/s); per-key state is two numbers, so 10 million active keys is a few GB with overhead; batching cuts calls for the heaviest keys by ~100×. The main trade-off: exactness. Limits can be exceeded briefly by the batch size and during regional rebalancing, which is acceptable for fairness limits, and is why abuse-critical limits (login) use the exact, unbatched path.

  • Response: 429, Retry-After, RateLimit-Limit/Remaining/Reset, problem details; clients back off with jitter.
  • Pitfalls: integer refill math, wall clocks instead of monotonic ones, non-atomic INCR/EXPIRE, IP-only limits behind NAT, case-sensitive keys.

Check this part

  1. Which HTTP status code means "too many requests"?

  2. Why limit logins per account and per IP?

  3. A bucket adds tokens for whole elapsed seconds only, then sets lastRefill = now. A client retries every 300 ms. What happens?

  4. The rate limiter's Redis is down. What should login and product search do?

Limited clients get 429 with Retry-After and RateLimit headers, and should back off with jitter. Refill math must use fractional time, and login limits need two keys: per account and per IP.

Key takeaways

  • Start with questions: who is limited, what, how strictly, where it runs, and what clients see.
  • The token bucket is the common answer: bursts up to its capacity, then a steady refill rate.
  • Shared counters (Redis) enforce one limit across many servers; limited clients get 429 with Retry-After.
  • Use the right keys: per account and per IP for logins, normalized consistently.
  • Make counter updates atomic with Lua, including expiry; use monotonic time and fractional refills.
  • Sliding window counters and token buckets give near-exact limits with two numbers per key.
  • Trade exactness for latency deliberately: batched token leases, regional shares of global limits.
  • Choose fail-open or fail-closed per endpoint, and separate fairness limits from load shedding.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.