HinterBuild logoHinterBuild
Backend Systems · 11 min read

Rate Limiting Strategies Beyond Simple Counters

Rate Limiting Strategies Beyond Simple Counters guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable,.

Muhammad Abdul Sami, author

Muhammad Abdul Sami

· Updated · 11 min read

  • RAG
  • Embeddings
  • Vector Databases
  • Evaluation

Table of Contents:

Why Simple Counters Fail in Production

Short answer: A naive fixed-window counter allows 2× burst traffic at window boundaries and provides no smooth traffic shaping — production APIs need token bucket or sliding window algorithms instead.

The most common rate limiting implementation — increment a counter, reject when it exceeds the limit — works in demos and breaks in production. At the boundary between two time windows, a client can send the full quota in the last second of window one and the full quota in the first second of window two — effectively doubling their allowed rate.

We implement rate limiting strategies on every backend API engineering engagement. This guide covers the algorithms that actually work at scale: token bucket, sliding window, leaky bucket, distributed Redis implementations, and adaptive throttling for APIs serving millions of requests.

Key Takeaways:

  • Token bucket is the best default — allows controlled bursts while enforcing average rate
  • Sliding window eliminates boundary burst problems but costs more Redis memory
  • Distributed rate limiting requires Redis (or similar) — in-memory counters break with multiple servers
  • Return Retry-After headers and structured 429 responses — clients need to know when to retry
  • Rate limit by identity (API key, user ID), not IP — IPs are shared and spoofable

Token Bucket: The Production Default

The token bucket algorithm maintains a bucket of tokens that refill at a constant rate. Each request consumes one token. When the bucket is empty, requests are rejected.

How It Works

Bucket capacity: 100 tokens
Refill rate: 10 tokens/second

Time 0s:  Bucket has 100 tokens (full)
          → 50 requests arrive → 50 tokens consumed → 50 remaining
Time 1s:  10 tokens refilled → 60 tokens
          → 80 requests arrive → 60 consumed, 20 rejected (429)
Time 2s:  10 tokens refilled → 10 tokens
          → traffic smooths out

Key property: Allows bursts up to bucket capacity while enforcing average rate over time. This matches real traffic patterns — users burst, then go quiet.

Redis Implementation

python
import redis.asyncio as redis
import time

redis_client = redis.from_url("redis://cache.internal:6379")

class TokenBucketRateLimiter:
    def __init__(
        self,
        redis_client: redis.Redis,
        key_prefix: str = "rl:token",
    ):
        self.redis = redis_client
        self.key_prefix = key_prefix

    async def is_allowed(
        self,
        identifier: str,
        max_tokens: int,
        refill_rate: float,  # tokens per second
        tokens_requested: int = 1,
    ) -> tuple[bool, dict]:
        """
        Returns (allowed, metadata) where metadata includes
        remaining tokens and retry_after seconds.
        """
        key = f"{self.key_prefix}:{identifier}"
        now = time.time()
        lua_script = """
        local key = KEYS[1]
        local max_tokens = tonumber(ARGV[1])
        local refill_rate = tonumber(ARGV[2])
        local now = tonumber(ARGV[3])
        local requested = tonumber(ARGV[4])

        local data = redis.call('HMGET', key, 'tokens', 'last_refill')
        local tokens = tonumber(data[1]) or max_tokens
        local last_refill = tonumber(data[2]) or now

        -- Refill tokens based on elapsed time
        local elapsed = now - last_refill
        tokens = math.min(max_tokens, tokens + (elapsed * refill_rate))

        local allowed = 0
        if tokens >= requested then
            tokens = tokens - requested
            allowed = 1
        end

        redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
        redis.call('EXPIRE', key, math.ceil(max_tokens / refill_rate) + 1)

        return {allowed, math.floor(tokens)}
        """

        result = await self.redis.eval(
            lua_script, 1, key,
            max_tokens, refill_rate, now, tokens_requested,
        )

        allowed = bool(result[0])
        remaining = int(result[1])
        retry_after = (1 / refill_rate) if not allowed else 0

        return allowed, {
            "remaining": remaining,
            "limit": max_tokens,
            "retry_after": retry_after,
        }

FastAPI Middleware Integration

python
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware

class RateLimitMiddleware(BaseHTTPMiddleware):
    def __init__(self, app, limiter: TokenBucketRateLimiter):
        super().__init__(app)
        self.limiter = limiter

    async def dispatch(self, request: Request, call_next):
        # Identify by API key or user ID — not IP
        api_key = request.headers.get("X-API-Key", request.client.host)
        tier = await get_tier_for_key(api_key)

        allowed, meta = await self.limiter.is_allowed(
            identifier=api_key,
            max_tokens=tier.burst_limit,
            refill_rate=tier.requests_per_second,
        )

        if not allowed:
            return JSONResponse(
                status_code=429,
                content={
                    "error": "rate_limit_exceeded",
                    "message": f"Rate limit exceeded. Retry after {meta['retry_after']:.1f}s",
                    "retry_after": meta["retry_after"],
                },
                headers={
                    "Retry-After": str(int(meta["retry_after"]) + 1),
                    "X-RateLimit-Limit": str(meta["limit"]),
                    "X-RateLimit-Remaining": str(meta["remaining"]),
                },
            )

        response = await call_next(request)
        response.headers["X-RateLimit-Limit"] = str(meta["limit"])
        response.headers["X-RateLimit-Remaining"] = str(meta["remaining"])
        return response

Deploy rate-limited APIs through our backend API engineering services with tier configuration documented in your OpenAPI spec.


Sliding Window: Precise Rate Control

The sliding window algorithm counts requests in a rolling time window — eliminating the boundary burst problem that fixed-window counters have.

Fixed Window vs Sliding Window

Fixed window (limit: 5 req/min):
  Window 1 [0:00-0:59]: █████ (5 requests — at limit)
  Window 2 [1:00-1:59]: █████ (5 requests at 1:00 — 10 in 1 second!)

Sliding window (limit: 5 req/min):
  At any point, count requests in the last 60 seconds
  → Maximum 5 requests in any 60-second span
  → No boundary burst possible

Redis Sliding Window Log

python
class SlidingWindowRateLimiter:
    async def is_allowed(
        self,
        identifier: str,
        max_requests: int,
        window_seconds: int,
    ) -> tuple[bool, dict]:
        key = f"rl:sliding:{identifier}"
        now = time.time()
        window_start = now - window_seconds

        lua_script = """
        local key = KEYS[1]
        local now = tonumber(ARGV[1])
        local window_start = tonumber(ARGV[2])
        local max_requests = tonumber(ARGV[3])
        local window_seconds = tonumber(ARGV[4])

        -- Remove expired entries
        redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)

        -- Count current window requests
        local current_count = redis.call('ZCARD', key)

        if current_count < max_requests then
            redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))
            redis.call('EXPIRE', key, window_seconds)
            return {1, max_requests - current_count - 1}
        else
            return {0, 0}
        end
        """

        result = await self.redis.eval(
            lua_script, 1, key,
            now, window_start, max_requests, window_seconds,
        )

        allowed = bool(result[0])
        remaining = int(result[1])

        return allowed, {
            "remaining": remaining,
            "limit": max_requests,
            "window_seconds": window_seconds,
        }

Sliding Window Counter (Memory-Efficient Hybrid)

For high-traffic APIs, the log-based sliding window uses too much Redis memory. The sliding window counter approximates with two fixed windows:

python
async def sliding_window_counter(
    identifier: str,
    max_requests: int,
    window_seconds: int,
) -> bool:
    now = time.time()
    current_window = int(now // window_seconds)
    previous_window = current_window - 1

    current_key = f"rl:sw:{identifier}:{current_window}"
    previous_key = f"rl:sw:{identifier}:{previous_window}"

    current_count = int(await redis.get(current_key) or 0)
    previous_count = int(await redis.get(previous_key) or 0)

    # Weight previous window by how much of it falls in the sliding window
    elapsed_in_current = now % window_seconds
    weight = 1 - (elapsed_in_current / window_seconds)

    estimated_count = (previous_count * weight) + current_count

    if estimated_count >= max_requests:
        return False

    await redis.incr(current_key)
    await redis.expire(current_key, window_seconds * 2)
    return True

Trade-off: Approximate (not exact) but uses O(1) Redis memory per identifier vs O(n) for the log approach. Redis's own rate limiter uses this algorithm.


Leaky Bucket: Smooth Traffic Shaping

The leaky bucket queues requests and processes them at a fixed rate — smoothing bursts into a steady stream. Unlike token bucket (which allows bursts), leaky bucket eliminates bursts entirely.

When to Use Leaky Bucket

  • Downstream services that cannot handle bursts (legacy databases, third-party APIs)
  • Background job processing where steady throughput matters more than latency
  • Protecting shared resources (connection pools — see our connection pooling guide)
python
import asyncio
from collections import deque

class LeakyBucket:
    def __init__(self, capacity: int, leak_rate: float):
        self.capacity = capacity
        self.leak_rate = leak_rate  # requests per second
        self.queue: deque = deque()
        self._leak_task = None

    async def add(self, request_id: str, timeout: float = 30.0) -> bool:
        if len(self.queue) >= self.capacity:
            return False  # bucket full — reject immediately

        future = asyncio.get_event_loop().create_future()
        self.queue.append((request_id, future))

        if self._leak_task is None:
            self._leak_task = asyncio.create_task(self._leak())

        try:
            await asyncio.wait_for(future, timeout=timeout)
            return True
        except asyncio.TimeoutError:
            return False

    async def _leak(self):
        interval = 1.0 / self.leak_rate
        while self.queue:
            _, future = self.queue.popleft()
            if not future.done():
                future.set_result(True)
            await asyncio.sleep(interval)
        self._leak_task = None

Algorithm Comparison

AlgorithmBurst HandlingMemoryPrecisionBest For
Fixed window counterAllows 2× at boundaryO(1)LowNever (use token bucket instead)
Token bucketControlled burstsO(1)HighDefault for most APIs
Sliding window logNo burstsO(n)ExactStrict compliance requirements
Sliding window counterMinimal burstsO(1)ApproximateHigh-traffic APIs
Leaky bucketNo bursts (queues)O(n)HighDownstream protection

Distributed Rate Limiting with Redis

In-memory rate limiting breaks the moment you deploy a second server instance. Distributed rate limiting requires shared state — Redis is the standard choice.

Architecture

                    ┌──────────────┐
  Request ─────────►│  API Server 1│──┐
                    └──────────────┘  │
                    ┌──────────────┐  │    ┌─────────────┐
  Request ─────────►│  API Server 2│──┼───►│    Redis     │
                    └──────────────┘  │    │ (rate state) │
                    ┌──────────────┐  │    └─────────────┘
  Request ─────────►│  API Server 3│──┘
                    └──────────────┘

All server instances read and write rate limit state to the same Redis cluster. Lua scripts ensure atomicity — no race conditions between instances.

Redis Cluster Considerations

  • Use hash tags in keys to co-locate related data: rl:{api_key}:tokens
  • Set appropriate TTL on all rate limit keys — stale keys accumulate without expiry
  • Monitor Redis memory usage — rate limiting keys are high-cardinality (one per API key/user)
  • Use Redis Cluster or ElastiCache for HA — rate limiter failure should fail open (allow requests), not block all traffic
python
async def is_allowed_fail_open(limiter, *args, **kwargs) -> tuple[bool, dict]:
    """Fail open if Redis is unavailable — availability over strict limiting."""
    try:
        return await limiter.is_allowed(*args, **kwargs)
    except redis.ConnectionError:
        logger.warning("Rate limiter Redis unavailable — failing open")
        return True, {"remaining": -1, "limit": -1, "retry_after": 0}

Deploy Redis clusters for rate limiting through our cloud infrastructure and DevOps practice — including ElastiCache sizing and failover configuration.

For Kubernetes platform engineering deployments, run Redis as a dedicated cluster (not sidecar) with persistent storage and sentinel/cluster mode.


Tier-Based Quotas and API Keys

Production APIs rarely apply a single rate limit to all users. Tier-based rate limiting maps API keys or subscription tiers to different limits.

Tier Configuration

python
from dataclasses import dataclass

@dataclass
class RateLimitTier:
    name: str
    requests_per_second: float
    burst_limit: int
    daily_quota: int

TIERS = {
    "free": RateLimitTier("free", requests_per_second=1, burst_limit=10, daily_quota=1000),
    "starter": RateLimitTier("starter", requests_per_second=10, burst_limit=50, daily_quota=50000),
    "pro": RateLimitTier("pro", requests_per_second=50, burst_limit=200, daily_quota=500000),
    "enterprise": RateLimitTier("enterprise", requests_per_second=200, burst_limit=1000, daily_quota=-1),
}

async def get_tier_for_key(api_key: str) -> RateLimitTier:
    tier_name = await redis.get(f"api_key_tier:{api_key}")
    return TIERS.get(tier_name or "free", TIERS["free"])

Multi-Dimensional Rate Limiting

Apply limits across multiple dimensions simultaneously:

python
async def check_all_limits(api_key: str, endpoint: str) -> bool:
    tier = await get_tier_for_key(api_key)

    checks = [
        # Per-key global limit
        limiter.is_allowed(f"key:{api_key}", tier.burst_limit, tier.requests_per_second),
        # Per-endpoint limit (prevent one endpoint abuse)
        limiter.is_allowed(f"endpoint:{endpoint}", 100, 50),
        # Per-key daily quota
        daily_limiter.is_allowed(f"daily:{api_key}", tier.daily_quota, tier.daily_quota / 86400),
    ]

    results = await asyncio.gather(*[c for c in checks])
    return all(r[0] for r in results)

GraphQL Query Cost Limiting

GraphQL endpoints need query complexity rate limiting, not just request counting. A single GraphQL query can trigger 50 database calls.

python
def calculate_query_cost(query: str, max_depth: int = 10) -> int:
    """Assign cost based on query depth and field count."""
    parsed = parse(query)
    depth = measure_depth(parsed)
    field_count = count_fields(parsed)

    if depth > max_depth:
        raise QueryTooComplexError(f"Query depth {depth} exceeds max {max_depth}")

    return depth * field_count  # cost = depth × fields

async def check_graphql_limit(api_key: str, query: str) -> bool:
    cost = calculate_query_cost(query)
    tier = await get_tier_for_key(api_key)
    allowed, _ = await limiter.is_allowed(
        f"gql:{api_key}",
        max_tokens=tier.burst_limit,
        refill_rate=tier.requests_per_second,
        tokens_requested=cost,  # expensive queries consume more tokens
    )
    return allowed

See our gRPC vs REST vs GraphQL guide for API protocol context.


Adaptive and Dynamic Rate Limiting

Static rate limits do not adapt to changing conditions. Adaptive rate limiting adjusts thresholds based on system health.

Backend Health-Based Throttling

python
async def get_adaptive_limit(base_limit: float) -> float:
    """Reduce rate limit when backend is under stress."""
    db_pool_utilization = await metrics.get("db.pool.active_ratio")
    cpu_utilization = await metrics.get("system.cpu_percent")
    error_rate = await metrics.get("api.error_rate_5m")

    multiplier = 1.0

    if db_pool_utilization > 0.8:
        multiplier *= 0.5  # halve limits when DB pool stressed
    if cpu_utilization > 0.85:
        multiplier *= 0.7
    if error_rate > 0.05:
        multiplier *= 0.3  # aggressive throttling during error spike

    return base_limit * multiplier

Priority-Based Rate Limiting

Not all requests are equal. Reserve capacity for critical operations:

python
PRIORITY_LIMITS = {
    "critical": {"share": 0.4, "min_rate": 100},   # 40% reserved
    "standard": {"share": 0.5, "min_rate": 50},      # 50% shared
    "background": {"share": 0.1, "min_rate": 10},    # 10% best-effort
}

async def check_priority_limit(
    api_key: str,
    priority: str,
    global_limit: float,
) -> bool:
    config = PRIORITY_LIMITS[priority]
    priority_limit = max(config["min_rate"], global_limit * config["share"])

    return await limiter.is_allowed(
        f"priority:{priority}:{api_key}",
        max_tokens=int(priority_limit * 10),
        refill_rate=priority_limit,
    )

Monitor adaptive rate limiting effectiveness with observability and monitoring — track 429 response rates, retry patterns, and correlation with backend health metrics.


Production Case Study: API Rate Limit Migration

A SaaS client migrated from nginx limit_req (fixed window, per-IP) to application-level token bucket (per-API-key) with tier-based quotas.

Before:

  • Fixed window counter at nginx layer
  • Per-IP limiting (broken for corporate NAT — one IP, 500 users)
  • No tier differentiation
  • Boundary burst allowed 2× traffic every minute

After:

  • Token bucket in FastAPI middleware with Redis backend
  • Per-API-key limiting with 4 tiers (free, starter, pro, enterprise)
  • Sliding window counter for daily quotas
  • Adaptive throttling tied to database pool utilization

Results:

  • 429 responses decreased 34% (fewer false rejections from NAT sharing)
  • Revenue from tier upgrades increased 22% in Q3 (free users hit limits, upgraded)
  • Zero rate-limit-related outages during Black Friday (adaptive throttling activated automatically)
  • P99 latency improved 18% (adaptive throttling prevented DB pool exhaustion)

Related implementation guides:

Primary references: official documentation, official documentation, official documentation, official documentation.

Operating Rate Limiting Strategies Beyond Simple Counters as a System

The implementation is only one part of Rate Limiting Strategies Beyond Simple Counters. A production design also needs an explicit contract for inputs, outputs, ownership, and failure behavior. Write that contract before selecting a library. It should identify which component validates input, where state lives, what may be retried, and which result is authoritative when two components disagree. This prevents a convenient prototype boundary from silently becoming the long-term architecture.

Start with a representative baseline. Capture request shape, traffic distribution, dependency latency, error classes, and the quality signal users actually care about. Averages hide the cases that cause incidents, so keep percentiles and segment measurements by workload type. Record the configuration and dataset version beside every result. Without that context, a faster or more accurate run cannot be reproduced and should not be used to approve a rollout.

Define the failure model

List failures by where they originate: invalid input, capacity exhaustion, dependency timeout, partial state change, malformed output, and semantically wrong output. Each class needs a different response. Validation errors should fail immediately. Transient dependency failures may be retried with a budget and jitter. An operation that may have committed must use an idempotency key or reconciliation step before retrying. A syntactically valid but incorrect result belongs in evaluation and review, not a blind retry loop.

Set a deadline for the complete operation and derive smaller budgets for each dependency. Local timeouts that add up to more than the caller's deadline merely create abandoned work. Propagate cancellation where the protocol supports it. Bound every queue, retry loop, context buffer, and concurrency pool; an unbounded safety mechanism becomes a second outage during overload.

Design a degraded mode before it is needed. Depending on the workload, that can mean returning a cached answer, selecting a simpler path, placing work in a durable queue, or asking for human review. The degraded response must be visible in telemetry and, where it changes meaning, visible to the caller. Silent fallback makes quality regressions almost impossible to diagnose.

Measure the decision, not just the component

Use three layers of signals. System metrics cover latency, throughput, saturation, and errors. Correctness metrics measure whether the result satisfies its contract. Business or user metrics show whether the system solved the intended problem. Improving only one layer can move the others backward, so release criteria should name acceptable movement for all three.

Attach a reason code to every route, rejection, fallback, and retry. Include version identifiers for configuration, code, model, schema, and data when relevant. Logs should let an engineer reconstruct a decision without storing secrets or raw personal data. Traces should cross process boundaries, while metrics should remain low-cardinality enough to operate reliably.

Alert on symptoms that require action, not every internal anomaly. A useful alert names the affected service objective, links to a runbook, and distinguishes a customer-visible incident from exhausted headroom. Dashboards serve a different purpose: they support diagnosis and capacity planning. Treating a dashboard as an alerting strategy leaves failures undiscovered until someone happens to look.

Roll out with reversible steps

Ship Rate Limiting Strategies Beyond Simple Counters behind a versioned interface and a kill switch. Begin with offline replay using production-shaped, privacy-safe samples. Then use shadow execution when duplicate work has acceptable cost and side effects can be suppressed. A small canary should exercise the real dependency graph before traffic expands. Compare the canary with the baseline by cohort rather than mixing both populations into one aggregate.

Promotion gates should be written before the rollout. Include a minimum sample size or observation window, maximum regression in tail latency and error rate, and a correctness threshold. Roll back automatically when a hard safety boundary is crossed; use manual review for ambiguous quality movement. Preserve enough evidence from both paths to explain why the gate passed or failed.

Configuration deserves the same discipline as code. Review changes, validate them before activation, keep an immutable history, and make rollback a single operation. If a deployment changes code and configuration together, record both versions. Otherwise an incident responder may roll back the binary while leaving the triggering configuration active.

Capacity and cost controls

Model capacity in units the bottleneck understands: concurrent connections, tokens, queue jobs, database transactions, GPU memory, or bytes in flight. Convert the expected traffic distribution into those units and include burst behavior. Then load-test the first constrained dependency, not merely the public endpoint. A system that accepts more work than it can finish within its deadline is overloaded even if CPU utilization looks comfortable.

Cost is also a reliability limit. Add per-request attribution, tenant or workflow budgets, and a global circuit breaker for unexpectedly expensive paths. Review unit economics at the same granularity as performance; a cheap median can conceal a small class of requests responsible for most spend. Optimize only after measuring, because reducing context, replicas, validation, or redundancy can trade visible cost for less visible risk.

Production readiness review

Before launch, ask an engineer who did not build the feature to follow the runbook through one simulated failure. Verify backups or checkpoints by restoring them, not by checking that a job reported success. Exercise credential rotation, dependency unavailability, bad configuration, and rollback. Assign an owner for each alarm and a date for reviewing thresholds after real traffic arrives.

The final architecture document should be short enough to remain current. Keep the decision, rejected alternatives, invariants, dependency contracts, dashboards, and rollback procedure. Link detailed experiments rather than pasting them into the document. Teams that need help turning this review into an operable service can use our Rate Limiting Strategies Beyond Simple Counters engineering support.

Frequently Asked Questions

What is the best rate limiting algorithm for APIs?

Token bucket is the best default for most APIs. It allows controlled bursts while enforcing average rate over time. Use sliding window when you need strict "no more than N requests in any 60-second period" guarantees.

What is the difference between rate limiting and throttling?

Rate limiting rejects requests that exceed the threshold (returns 429). Throttling queues excess requests and processes them later (leaky bucket). Rate limiting protects your API; throttling shapes traffic to protect downstream services.

How do I rate limit in a distributed system?

Use Redis (or similar shared store) with atomic Lua scripts. In-memory counters break with multiple server instances. All instances must read/write the same rate limit state.

Should I rate limit by IP or API key?

API key (or authenticated user ID) for identified clients. IP only as a fallback for unauthenticated endpoints. IP-based limiting breaks behind corporate NAT and is bypassable with proxies.

What HTTP status code should rate-limited requests return?

429 Too Many Requests with a Retry-After header indicating seconds until the client should retry. Include X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers for client-side rate tracking.

How do I rate limit GraphQL APIs?

Limit by query complexity (depth × field count), not request count. A single GraphQL query can fan out to dozens of database calls. Assign token costs proportional to query complexity.

Should rate limiting fail open or fail closed?

Fail open (allow requests when Redis is down) for most APIs — availability matters more than strict limiting during infrastructure failures. Fail closed only for security-critical endpoints where unbounded access is worse than downtime.

How do I test rate limiting?

Send requests at exactly the limit, above the limit, and in burst patterns. Verify 429 responses include correct headers. Test concurrent requests from multiple clients. Test behavior when Redis is unavailable (fail open/closed).


Conclusion

Rate limiting strategies beyond simple counters are essential for production APIs that serve real traffic patterns:

  • Token bucket as the default algorithm — controlled bursts, enforced average rate
  • Redis-backed distributed limiting — required for multi-instance deployments
  • Tier-based quotas — different limits for different customers
  • Adaptive throttling — reduce limits when backend health degrades
  • Structured 429 responsesRetry-After and rate limit headers for client compliance

At HinterBuild:

Schedule a consultation for API throttling architecture review.

Free consultation

Book a free consultation call on rate limiting & API throttling

30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.

Book a meeting

Keep reading