Rate Limiting and Throttling Patterns

Implement rate limiting that protects your APIs from abuse without degrading legitimate traffic. Covers token bucket, sliding window, distributed rate limiting, client-specific quotas, and graceful degradation strategies.

Rate limiting and throttling prevent systems from being overwhelmed by client requests, especially in APIs and microservices. When a client floods a service with calls—whether from a mobile app, a background job, or a third-party integration—the system must balance load, resource usage, and response latency. These patterns ensure that services remain responsive under varying traffic, protect downstream dependencies, and provide predictable performance. They are essential in any system where clients can burst, batch, or continuously poll, and where resources such as database connections, CPU, or network bandwidth are finite.

Request-Level Rate Limiting

Apply rate limiting per client, per user, or per IP address to control how many requests a client can make in a fixed time window. The most common approach uses a sliding window algorithm with a token bucket or fixed window counter.

A client receives a 200 OK response with headers indicating the current rate limit state:

HTTP/1.1 200 OK
Content-Type: application/json
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 97
X-RateLimit-Reset: 1711234567

The client uses X-RateLimit-Limit to learn the maximum allowed requests per window. X-RateLimit-Remaining tracks available tokens. X-RateLimit-Reset is a Unix timestamp indicating when the window resets.

After 97 requests, the client receives a 429 Too Many Requests response:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711234567

{
  "error": "Rate limit exceeded",
  "message": "You have reached the maximum number of requests per minute",
  "retry_after": 60
}

The Retry-After header suggests when to retry, either in seconds (60) or as a timestamp (1711234567).

Configuring Rate Limiting in a Microservice

Use a configuration file to define limits per route, client, or user:

rate_limits:
  default:
    window: 60s
    limit: 100
    strategy: sliding_window
    storage: redis
    key_template: "rate_limit:{client_id}:{path}"
    burst: 20
  api/v1/users:
    window: 10s
    limit: 50
    strategy: fixed_window
    storage: redis
    key_template: "rate_limit:{user_id}:{path}"
    burst: 10

Each route applies its own rules. The sliding_window strategy uses Redis to maintain a list of timestamps for each request, allowing precise windowing. The fixed_window strategy increments a counter for each window period (e.g., every 10 seconds) and resets the counter at the interval boundary.

Per-Client Throttling with Dynamic Windowing

For clients that vary in behavior—some bursty, others steady—use dynamic windowing. The system tracks request arrival times and adjusts the window size based on traffic patterns.

When a client sends requests at irregular intervals, the service calculates the effective window using a sliding window of requests. It maintains a list of timestamps in a Redis sorted set:

ZADD rate_limit:client_123:api/v1/users 1711234500.123 1
ZADD rate_limit:client_123:api/v1/users 1711234512.456 1
ZADD rate_limit:client_123:api/v1/users 1711234520.001 1

A query retrieves the count of requests within the last 60 seconds:

ZRANGEBYSCORE rate_limit:client_123:api/v1/users -inf 1711234560.0

The response includes the count and the next available window. If the count exceeds the limit, the service returns 429 with X-RateLimit-Remaining: 0 and Retry-After: 30.

Handling Burst Traffic

Clients sending 100 requests in 3 seconds must be allowed to burst beyond the base limit. The burst configuration enables a "token bucket" model where clients accumulate additional tokens beyond their base limit.

A client with limit: 50, burst: 20, and window: 10s can send 70 requests in a single burst, consuming 20 extra tokens. The system tracks two values:

On each request, the system updates the token count:

def update_token_bucket(client_id, path, rate_limit_config):
    # Get current state
    tokens = redis.hget(f"bucket:{client_id}:{path}", "tokens")
    last_updated = redis.hget(f"bucket:{client_id}:{path}", "last_updated")

    # Calculate elapsed time
    now = time.time()
    elapsed = now - float(last_updated or now)

    # Add new tokens
    new_tokens = elapsed * rate_limit_config["rate"]
    tokens += new_tokens

    # Cap tokens
    tokens = min(tokens, rate_limit_config["limit"] + rate_limit_config["burst"])

    # Use one token
    if tokens >= 1:
        tokens -= 1
        redis.hset(f"bucket:{client_id}:{path}", "tokens", tokens)
        redis.hset(f"bucket:{client_id}:{path}", "last_updated", now)
        return True, tokens
    else:
        return False, tokens

The client receives 429 with Retry-After set to the time needed to refill the bucket.

Global Throttling Across Multiple Services

When multiple services share a common data layer—such as a database or a message queue—throttle at the system level. This prevents any single service from overwhelming the shared resource.

A central throttler service monitors request counts, response times, and resource utilization. It adjusts the rate limits on all services dynamically.

A GET /throttling/status endpoint returns the current state:

{
  "services": {
    "user-service": {
      "rate_limit": 100,
      "current_requests": 94,
      "total_capacity": 200,
      "throttled": true,
      "reason": "Database load above 80%"
    },
    "order-service": {
      "rate_limit": 75,
      "current_requests": 75,
      "total_capacity": 150,
      "throttled": true,
      "reason": "CPU utilization at 95%"
    }
  },
  "global": {
    "database_load": 0.85,
    "message_queue_depth": 1200,
    "cpu_utilization": 0.92
  }
}

When the database load exceeds 80%, the throttler sends a PATCH /throttling request to all services:

PATCH /throttling
Content-Type: application/json

{
  "user-service": {
    "rate_limit": 80,
    "reason": "Database load increased"
  },
  "order-service": {
    "rate_limit": 60,
    "reason": "High CPU and queue depth"
  }
}

Each service updates its rate limit configuration and applies the new limits immediately.

Enforcing Throttling with Middleware

In a Go microservice, a middleware function enforces rate limits before handling each request:

func RateLimitMiddleware(handler http.Handler, config RateLimitConfig) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        clientID := getClientID(r)
        path := r.URL.Path

        key := fmt.Sprintf("rate_limit:%s:%s", clientID, path)
        client := redis.NewClient(&redis.Options{
            Addr: "redis:6379",
        })

        // Get current count and window
        val, err := client.Get(key).Result()
        if err != nil || val == "" {
            val = "0"
        }

        count, _ := strconv.Atoi(val)
        window := config.Window

        // Check if limit exceeded
        if count >= config.Limit {
            w.Header().Set("X-RateLimit-Limit", strconv.Itoa(config.Limit))
            w.Header().Set("X-RateLimit-Remaining", "0")
            w.Header().Set("Retry-After", strconv.Itoa(int(window.Seconds())))
            w.WriteHeader(http.StatusTooManyRequests)
            w.Write([]byte(`{"error":"Rate limit exceeded","message":"Request limit reached"}`))
            return
        }

        // Increment counter
        client.Incr(key)
        client.Expire(key, window)

        // Set headers
        w.Header().Set("X-RateLimit-Limit", strconv.Itoa(config.Limit))
        w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(count+1))
        w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(time.Now().Add(window).Unix(), 10))

        // Call next handler
        handler.ServeHTTP(w, r)
    })
}

The middleware is applied globally:

mux := http.NewServeMux()
mux.Handle("/api/v1/users/", RateLimitMiddleware(userHandler, userConfig))
mux.Handle("/api/v1/orders/", RateLimitMiddleware(orderHandler, orderConfig))

Prioritized Throttling for Critical Workloads

For systems with multiple request types—some time-critical, others batch-oriented—use prioritized throttling. High-priority requests are processed first and have their own rate limits.

A request includes a X-Priority header:

POST /api/v1/events
X-Priority: high
Content-Type: application/json

{
  "event_type": "user_login",
  "user_id": 12345,
  "timestamp": "2024-03-20T10:00:00Z"
}

The service maintains separate rate limit counters for each priority level:

Requests are processed in priority order. When a high-priority request arrives, the service checks if the high-priority bucket is full. If so, it enforces 429 with X-Priority-Status: throttled.

Handling Priority Inversion

When a high-priority request is blocked by a long-running low-priority job, the system can apply priority boosting. The service maintains a priority queue of active requests and uses a worker pool.

Each worker polls a shared Redis list:

BRPOPLPUSH pending:high priority:high 30
BRPOPLPUSH pending:medium priority:medium 30
BRPOPLPUSH pending:low  priority:low  30

When a worker completes a high-priority task, it checks if the next high-priority request is ready:

LLEN priority:high

If the list is empty, the worker can idle, but if it’s non-empty, it processes the next request immediately.

Configuring Prioritized Throttling

A service uses a configuration file with per-priority settings:

rate_limits:
  priority:
    high:
      window: 60s
      limit: 200
      burst: 30
      storage: redis
      key_template: "rate_limit:{client_id}:high:{path}"
    medium:
      window: 60s
      limit: 100
      burst: 15
      storage: redis
      key_template: "rate_limit:{client_id}:medium:{path}"
    low:
      window: 60s
      limit: 50
      burst: 10
      storage: redis
      key_template: "rate_limit:{client_id}:low:{path}"

Clients use X-Priority to indicate their intent, and the system uses X-RateLimit-Remaining and Retry-After to guide them.

Handling Throttling Failures

Throttling silently fails when the storage backend becomes unavailable. Redis or a database may be down, causing rate limit updates to be lost.

When a service cannot update the rate limit counter, it logs an error and falls back to in-memory tracking:

{
  "error": "Rate limit update failed",
  "service": "user-service",
  "client_id": "client_123",
  "path": "/api/v1/users",
  "timestamp": "2024-03-20T10:05:30Z",
  "error_code": "redis_unavailable",
  "retry_after": 30
}

If the storage fails consistently, the system increases the fallback window and sends alerts.

A common failure is misinterpreting window and reset. A client assuming a fixed window may retry too early if the system uses a sliding window. The X-RateLimit-Reset header must be accurate—its value is a Unix timestamp, not a duration.

Another sharp edge is the Retry-After header. If Retry-After: 60, the client expects to wait 60 seconds before retrying. But if the server resets the window at a different time, the client may need to adjust its X-RateLimit-Remaining value.

Finally, when multiple throttling layers exist—client, service, and global—clients must be aware of cumulative limits. A client sending 100 requests per minute may be throttled at the service level (limit 50) and also at the global level (limit 80). The client must track both limits and respond to 429 from either layer.

Summary of Key Patterns

These patterns ensure that systems remain stable, predictable, and responsive under load, and that clients can adapt to changing conditions without explicit configuration.

This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.