Table of Contents

    rate limiting

    SYSTEM DESIGN • CHAPTER 13.8

    Rate Limiting

    Understand how rate limiting protects capacity, prevents abuse, and enforces fairness, which algorithm suits which workload, and how to make limits work correctly across a distributed fleet.

    Learning objective: By the end of this article, you will understand the four core limiting algorithms, how to choose an identity key, distributed counter design, cost-based limiting, correct client-facing responses, and how rate limiting relates to throttling, quotas, and load shedding.

    Prerequisites

    Recommended Knowledge

    • HTTP status codes and response headers
    • API gateways and reverse proxies
    • Authentication and client identity
    • Distributed caches such as Redis
    • Atomic operations and race conditions
    • Retries, backoff, and jitter
    • Latency percentiles and capacity planning
    • Multi-tenant isolation concepts

    What Rate Limiting Does

    Rate limiting restricts how many operations a given caller may perform within a defined period. When the limit is exceeded, the request is rejected or delayed rather than served.

    THE BASIC RULE
    \[ N_{\text{requests}} \leq L \text{ per } W \]

    Here \(L\) is the permitted count and \(W\) is the window. The apparent simplicity is deceptive: the behaviour at window boundaries, across many servers, and under bursty traffic is where the real design work lies.

    Simple Analogy

    A motorway ramp meter releases vehicles at a controlled pace. It does not increase the road's capacity, but it prevents a surge from collapsing throughput for everyone already travelling.

    Why Systems Need It

    Objective Problem Addressed Typical Scope
    Capacity protection Traffic surge exhausting resources Per service or endpoint
    Fairness One tenant consuming shared capacity Per tenant or account
    Abuse prevention Credential stuffing and scraping Per identity and per address
    Cost control Expensive downstream calls Per operation class
    Dependency protection Overwhelming a fragile backend Per downstream service
    Commercial tiering Differentiating plan entitlements Per subscription level
    Rate limiting does not make a system faster. It ensures that excess demand degrades a few callers rather than collapsing service for everyone.

    Related but Distinct Controls

    Control Question It Answers Time Horizon
    Rate limiting How fast may this caller send requests? Seconds to minutes
    Quota How much may this caller consume in total? Days to months
    Throttling Should this request be slowed rather than rejected? Per request
    Load shedding Is the system overloaded right now? Immediate, system-wide
    Concurrency limit How many requests may be in flight at once? Instantaneous
    Circuit breaker Is a dependency failing and should calls stop? Until recovery
    Important distinction: Rate limiting is per-caller and predictable. Load shedding is system-wide and reactive. A well-behaved caller within its limit may still be shed during genuine overload.

    Fixed Window Counter

    The simplest approach divides time into fixed intervals and counts requests within each. When the count exceeds the limit, further requests are rejected until the window resets.

    Advantages

    • Trivial to implement and reason about
    • Minimal memory, one counter per key
    • Fast atomic increment operations
    • Easy to explain to API consumers

    The Boundary Problem

    • Allows double the limit across a boundary
    • Encourages synchronized client bursts
    • Counter resets create traffic spikes
    • Poor approximation of a true rate
    WORST-CASE BURST
    \[ N_{\text{burst}} = 2L \text{ within } W \]

    A caller sending the full limit in the final moment of one window and again in the first moment of the next transmits twice the intended rate across a span shorter than a single window.

    Sliding Window Log

    This approach stores a timestamp for every request and counts those falling within the trailing window. It is exact, with no boundary artefact.

    async function slidingWindowLog(key, limit, windowMs) {
        const now = Date.now();
        const windowStart = now - windowMs;
        const redisKey = `ratelimit:log:${key}`;
    
        const results = await redis
            .multi()
            .zremrangebyscore(redisKey, 0, windowStart)
            .zcard(redisKey)
            .zadd(redisKey, now, `${now}-${randomSuffix()}`)
            .pexpire(redisKey, windowMs)
            .exec();
    
        const countBeforeAdd = results[1];
    
        if (countBeforeAdd >= limit) {
            await redis.zremrangebyscore(redisKey, now, now);
    
            return {
                allowed: false,
                remaining: 0,
                retryAfterMs: windowMs
            };
        }
    
        return {
            allowed: true,
            remaining: limit - countBeforeAdd - 1,
            retryAfterMs: 0
        };
    }
    Memory Cost Storing one entry per request means a caller permitted ten thousand requests per hour requires ten thousand stored timestamps. Multiplied across many callers, this becomes prohibitive.

    Sliding Window Counter

    This hybrid approximates the sliding window by weighting the previous window's count according to how far the current window has progressed. It captures most of the accuracy at a fraction of the memory cost.

    WEIGHTED ESTIMATE
    \[ N_{\text{est}} = C_{\text{current}} + C_{\text{previous}} \times \left(1 - \frac{t_{\text{elapsed}}}{W}\right) \]
    async function slidingWindowCounter(key, limit, windowMs) {
        const now = Date.now();
        const currentWindow = Math.floor(now / windowMs);
        const previousWindow = currentWindow - 1;
        const elapsedRatio = (now % windowMs) / windowMs;
    
        const currentKey = `ratelimit:${key}:${currentWindow}`;
        const previousKey = `ratelimit:${key}:${previousWindow}`;
    
        const [currentCount, previousCount] = await redis.mget(
            currentKey,
            previousKey
        );
    
        const estimated =
            Number(currentCount || 0) +
            Number(previousCount || 0) * (1 - elapsedRatio);
    
        if (estimated >= limit) {
            return {
                allowed: false,
                remaining: 0,
                resetMs: windowMs - (now % windowMs)
            };
        }
    
        await redis
            .multi()
            .incr(currentKey)
            .pexpire(currentKey, windowMs * 2)
            .exec();
    
        return {
            allowed: true,
            remaining: Math.floor(limit - estimated - 1),
            resetMs: windowMs - (now % windowMs)
        };
    }
    Practical Choice Two counters per key, no boundary doubling, and an accuracy that is more than sufficient for capacity protection. This is the common default for API rate limiting.

    Token Bucket

    A bucket holds tokens, refilled at a steady rate up to a maximum capacity. Each request consumes a token. An empty bucket means rejection.

    TOKEN REFILL
    \[ T_{\text{available}} = \min\left(B, T_{\text{last}} + r \times \Delta t\right) \]

    Here \(B\) is bucket capacity, \(r\) is the refill rate, and \(\Delta t\) is elapsed time. Capacity determines burst tolerance while the refill rate determines sustained throughput.

    const TOKEN_BUCKET_SCRIPT = `
    local key        = KEYS[1]
    local capacity   = tonumber(ARGV[1])
    local refillRate = tonumber(ARGV[2])
    local nowMs      = tonumber(ARGV[3])
    local cost       = tonumber(ARGV[4])
    
    local state    = redis.call('HMGET', key, 'tokens', 'updatedAt')
    local tokens   = tonumber(state[1])
    local updated  = tonumber(state[2])
    
    if tokens == nil then
        tokens  = capacity
        updated = nowMs
    end
    
    local elapsed = math.max(0, nowMs - updated) / 1000
    tokens = math.min(capacity, tokens + elapsed * refillRate)
    
    local allowed = 0
    
    if tokens >= cost then
        tokens  = tokens - cost
        allowed = 1
    end
    
    redis.call('HMSET', key, 'tokens', tokens, 'updatedAt', nowMs)
    redis.call('PEXPIRE', key, math.ceil(capacity / refillRate * 1000) + 1000)
    
    return { allowed, tokens }
    `;
    
    async function tokenBucket(key, config, cost = 1) {
        const [allowed, remaining] = await redis.eval(
            TOKEN_BUCKET_SCRIPT,
            1,
            `ratelimit:bucket:${key}`,
            config.capacity,
            config.refillRatePerSecond,
            Date.now(),
            cost
        );
    
        return {
            allowed: allowed === 1,
            remaining: Math.floor(remaining)
        };
    }
    Why token bucket is widely preferred: It tolerates natural bursts while bounding the sustained rate, which matches how real clients behave. A user who pauses and then performs several actions is accommodated.

    Leaky Bucket

    Requests enter a queue that drains at a constant rate. Overflow is rejected. The output rate is perfectly smooth, regardless of how irregular the input is.

    Property Token Bucket Leaky Bucket
    Burst handling Permitted up to capacity Smoothed into a steady stream
    Output rate Variable Constant
    Excess requests Rejected immediately Queued until overflow
    Added latency None when tokens remain Queue wait time
    Best suited to User-facing APIs Protecting a fixed-rate dependency

    Algorithm Selection

    Algorithm Memory per Key Accuracy When to Choose It
    Fixed window One counter Low Internal use where bursts are harmless
    Sliding window log One entry per request Exact Low-volume, high-sensitivity operations
    Sliding window counter Two counters High General-purpose API limiting
    Token bucket Token count and timestamp High When controlled bursts are desirable
    Leaky bucket Queue state High Protecting fixed-throughput backends

    Choosing the Limiting Key

    The identity used for counting determines whether the limit protects anything. A poorly chosen key either fails to stop abuse or punishes innocent users.

    Key Strength Weakness
    IP address Works for unauthenticated traffic Shared by offices and mobile carriers
    User identifier Precise attribution Unavailable before authentication
    API key Clear ownership and tiering Attacker may obtain many keys
    Tenant identifier Enforces multi-tenant fairness One tenant user can exhaust the allowance
    Session identifier Granular per active session Trivially reset by reconnecting
    Device fingerprint Survives address changes Imprecise and privacy sensitive
    Composite key Layers several dimensions Higher storage and complexity
    Address-Only Limiting A corporate network behind one address is throttled collectively, while a distributed attacker with thousands of addresses passes every check. The control fails in both directions.
    Layered Keys Apply limits at several levels simultaneously, so a request must satisfy the user limit, the tenant limit, and the address limit before proceeding.
    async function checkLayeredLimits(request) {
        const checks = [
            { key: `ip:${request.sourceIp}`,        limit: 600,  windowMs: 60_000 },
            { key: `user:${request.userId}`,        limit: 120,  windowMs: 60_000 },
            { key: `tenant:${request.tenantId}`,    limit: 5000, windowMs: 60_000 },
            { key: `endpoint:${request.userId}:${request.route}`, limit: 30, windowMs: 60_000 }
        ];
    
        for (const check of checks) {
            const result = await slidingWindowCounter(
                check.key,
                check.limit,
                check.windowMs
            );
    
            if (!result.allowed) {
                return {
                    allowed: false,
                    violatedScope: check.key.split(":")[0],
                    resetMs: result.resetMs
                };
            }
        }
    
        return { allowed: true };
    }

    Cost-Based Limiting

    Treating every request as equally expensive is a poor approximation. A bulk export and a status check consume vastly different resources, yet a simple counter charges them the same.

    WEIGHTED CONSUMPTION
    \[ C_{\text{total}} = \sum_{i=1}^{n} w_i \]
    Operation Relative Cost Rationale
    Read a single record Low Indexed lookup, often cached
    Paged list query Moderate Scans and sorts a result set
    Complex search High Fans out across index shards
    Bulk export Very high Large scan and sustained transfer
    Report generation Very high Aggregation over large datasets
    COST RULE
    Charge tokens in proportion to the resources an operation consumes. A limit expressed purely in request count invites callers to use the most expensive endpoint available.

    Distributed Enforcement

    With many application instances, a purely local counter permits far more traffic than intended. The effective limit becomes the configured limit multiplied by the instance count.

    LOCAL COUNTER DRIFT
    \[ L_{\text{effective}} = L_{\text{configured}} \times N_{\text{instances}} \]
    Approach Accuracy Latency Impact Failure Behaviour
    Local only Poor with many instances None Unaffected
    Centralized store High One network round trip Store becomes critical
    Local with sync Approximate Minimal Degrades gracefully
    Allocated budget Good when balanced Minimal Uneven under skew
    Sticky routing High per key None Rebalancing loses state

    Atomicity Is Mandatory

    Read-Then-Write Race Reading the counter, comparing it to the limit, and writing back allows concurrent requests to read the same value and all proceed. Under load, the limit is silently exceeded.
    Atomic Server-Side Evaluation Perform the check and update in a single atomic operation, such as a server-side script, so concurrent requests cannot observe a stale count.

    Failure Policy

    Fail Open

    • Allow requests when the store is unreachable
    • Preserves availability
    • Removes protection exactly when it may be needed
    • Usually paired with a local fallback limit

    Fail Closed

    • Reject requests when the check cannot run
    • Maintains protection guarantees
    • Converts a store outage into a full outage
    • Appropriate for security-critical endpoints
    Pragmatic middle ground: Fail open for general capacity limits with a conservative local fallback, and fail closed for authentication and other abuse-sensitive paths.

    Communicating Limits to Clients

    A rejection that carries no information forces clients to guess, and guessing typically means retrying immediately, which worsens the overload.

    HTTP/1.1 429 Too Many Requests
    Content-Type: application/json
    RateLimit-Limit: 120
    RateLimit-Remaining: 0
    RateLimit-Reset: 34
    Retry-After: 34
    {
        "error": "rate_limit_exceeded",
        "message": "Request limit exceeded for this endpoint.",
        "scope": "user",
        "limit": 120,
        "windowSeconds": 60,
        "retryAfterSeconds": 34,
        "requestId": "req-cc71e4"
    }
    Element Purpose
    Status 429 Signals rate limiting specifically, not a generic error
    Limit header Lets clients self-pace before hitting the ceiling
    Remaining header Enables proactive backoff
    Reset value Indicates when capacity returns
    Retry-After Gives an unambiguous wait instruction
    Scope field Clarifies which limit was violated
    Request identifier Supports diagnosis without exposing internals
    Using 503 for Rate Limits Clients interpret service-unavailable as transient and often retry aggressively, amplifying the very pressure the limit was meant to relieve.

    Client-Side Backoff

    Limits only work when clients respond sensibly. Synchronized retries produce a thundering herd at the moment the window resets.

    async function callWithBackoff(operation, options) {
        let attempt = 0;
    
        while (attempt < options.maxAttempts) {
            const response = await operation();
    
            if (response.status !== 429) {
                return response;
            }
    
            attempt += 1;
    
            const serverHint = Number(
                response.headers.get("Retry-After")
            );
    
            const baseDelayMs = Number.isFinite(serverHint) && serverHint > 0
                ? serverHint * 1000
                : options.baseDelayMs * Math.pow(2, attempt - 1);
    
            const jitterMs = Math.random() * baseDelayMs * 0.3;
    
            await sleep(baseDelayMs + jitterMs);
        }
    
        throw new Error("Rate limit retries exhausted");
    }
    JITTER IS NOT OPTIONAL
    Without randomization, every blocked client retries at the same instant. Jitter spreads the resumption and prevents a reset-triggered spike.

    Abuse-Specific Limits

    Some endpoints warrant far stricter treatment than general capacity limits, because the operation itself is the attack surface.

    Endpoint Attack Limiting Strategy
    Login Credential stuffing Limit per account and per address, with lockout
    Password reset Account enumeration Strict per-address limit, uniform responses
    Registration Bulk fake accounts Address limits plus additional verification
    One-time code send Cost abuse and harassment Per-recipient and per-sender limits
    Search Catalogue scraping Cost-weighted limits and pagination depth caps
    Export Data exfiltration Low limits with audit alerting
    Avoid a denial-of-service on the user: Locking an account after repeated failures lets an attacker disable any account at will. Prefer progressive delays and additional challenges over hard lockouts keyed solely on the victim.

    Threats and Mitigations

    Threat Description Mitigation
    Distributed sources Attack spread across many addresses Layer identity-based limits above address limits
    Header spoofing Forwarded-for header manipulated Trust only proxy-appended values
    Key rotation abuse Fresh sessions or keys reset counters Limit on durable identity and account age
    Expensive endpoint targeting Cheapest count, highest cost requests Cost-weighted consumption
    Race condition bypass Concurrent requests read a stale counter Atomic check-and-update
    Limiter as bottleneck Counter store becomes the constraint Sharding, pipelining, and local pre-filters
    Reset stampede All clients resume simultaneously Sliding windows and mandated jitter
    Enumeration via responses Different limits reveal valid accounts Uniform responses and timing

    Monitoring

    Signals Worth Tracking

    • Rejection rate by endpoint and by scope
    • Callers persistently at their ceiling
    • Distribution of consumption across tenants
    • Counter store latency and error rate
    • Fail-open activations and their duration
    • Retry rate following 429 responses
    • Traffic shape immediately after window reset
    • New identities appearing in bulk
    • Cost-weighted consumption against request count
    • Limit configuration changes
    A rejection rate of zero usually means the limits are set too high to protect anything, not that traffic is perfectly behaved.

    Common Design Mistakes

    Weak Design

    • Limiting solely by source address
    • Counting every request as equally expensive
    • Using local counters across many instances
    • Performing read-then-write without atomicity
    • Returning 429 with no retry guidance
    • Applying one global limit to all endpoints
    • Omitting jitter from client retries
    • Hard-coding limits requiring redeployment
    • Never monitoring rejection rates

    Strong Design

    • Layers address, user, and tenant limits
    • Weights consumption by operation cost
    • Shares state through an atomic store
    • Evaluates check and update as one operation
    • Returns limit, remaining, and reset headers
    • Tunes limits per endpoint sensitivity
    • Requires jittered client backoff
    • Makes limits configurable at runtime
    • Alerts on rejection anomalies

    System Design Interview Discussion

    Question What Your Answer Should Cover
    Which algorithm and why? Burst tolerance, memory, and accuracy trade-offs
    What is the limiting key? Layered identity dimensions and their weaknesses
    How does it work across instances? Shared atomic store versus local approximation
    What if the store fails? Fail-open and fail-closed reasoning per endpoint
    How do clients learn the limit? Status code, headers, and retry guidance
    How are costly endpoints handled? Cost-weighted token consumption
    How is a distributed attack handled? Identity-layer limits and upstream controls
    How do you avoid a retry storm? Sliding windows, Retry-After, and jitter

    Implementation Checklist

    Production Checklist

    • Select an algorithm matching your burst tolerance
    • Layer limits across address, user, and tenant
    • Weight consumption by operation cost
    • Set stricter limits on authentication endpoints
    • Share counters through an atomic store
    • Evaluate check and update in one operation
    • Define fail-open or fail-closed per endpoint
    • Provide a conservative local fallback
    • Return 429 with limit and reset headers
    • Include Retry-After on every rejection
    • Make limits configurable without deployment
    • Support per-tier and per-customer overrides
    • Require jittered backoff in client libraries
    • Avoid lockouts keyed solely on the victim
    • Keep responses uniform to prevent enumeration
    • Monitor rejection rates and store health
    • Load-test the limiter itself under peak traffic

    Knowledge Check

    1

    What is the fixed window boundary problem?

    A caller can send the full limit at the end of one window and again at the start of the next, transmitting twice the intended rate across a short span.

    2

    Why is token bucket commonly preferred?

    It permits natural bursts up to the bucket capacity while bounding the sustained rate through the refill rate, which matches real client behaviour.

    3

    Why is address-only limiting insufficient?

    Shared networks are penalized collectively while distributed attackers using many addresses evade the limit entirely.

    4

    Why must the check be atomic?

    Separate read and write operations let concurrent requests observe the same stale count and all proceed, silently exceeding the limit.

    5

    Why add jitter to retries?

    Without it, all blocked clients retry at the same moment, producing a synchronized spike when the window resets.

    Summary

    Rate limiting bounds how quickly a caller may consume a service, protecting capacity, enforcing fairness between tenants, and blunting abuse. It is distinct from quotas, throttling, and load shedding, which operate on different horizons.

    Fixed windows are simple but permit double the limit at boundaries. Sliding window logs are exact but memory-hungry. Sliding window counters offer a practical balance, while token buckets permit controlled bursts and leaky buckets smooth output for fragile dependencies.

    The limiting key matters as much as the algorithm. Address-only limits punish shared networks while missing distributed attacks, so layering address, user, and tenant dimensions is necessary. Cost weighting prevents callers from concentrating on the most expensive endpoints.

    Across a fleet, counters must be shared and updated atomically, with a deliberate failure policy. Rejections should return 429 with limit, remaining, reset, and Retry-After, and clients must back off with jitter to avoid a synchronized retry storm.

    Key Takeaway

    Limit on identity, weight by cost, and enforce atomically. Choose an algorithm matching your burst tolerance, layer limits across several identity dimensions, share counters through an atomic store, decide explicitly how to behave when that store fails, and tell clients exactly when to retry.