← Back to DevBytes

Designing a Rate Limiter with Multi-Region Deployment

What is a Multi-Region Rate Limiter?

A rate limiter controls the frequency of requests a client can make to an API or service within a given time window. In a multi-region deployment, the application runs in several geographic locations (e.g., US East, Europe, Asia) to reduce latency and improve availability. A multi-region rate limiter must enforce a consistent global limit across all regions while handling the inherent challenges of distributed systems: network latency, partial failures, and data consistency.

Without a multi-region design, each region would enforce its own independent counter, allowing a client to exceed the intended global limit by sending requests to different regions. For example, if the limit is 100 requests per minute and a client sends 60 to US East and 60 to Europe, the total 120 would violate the limit. A multi-region rate limiter synchronizes state across regions to prevent such abuse.

Why It Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Architecture Overview

A typical multi-region rate limiter uses a two-tier approach:

This hybrid design balances consistency and performance. The local limiter allows bursts up to its allocated capacity, while the global limiter prevents overflow beyond the total limit.

Design Approaches

Centralized Global Store

All regions read/write to a single Redis instance or cluster (e.g., via a global endpoint). Simple but introduces cross-region latency and a single point of failure.

Replicated Global Store

Use Redis with Active-Active replication (Redis Enterprise CRDT) or DynamoDB Global Tables. Each region writes locally and changes propagate asynchronously. Eventual consistency means a brief window where the global counter may be slightly off.

Decentralized with Synchronization

Each region maintains its own counter and periodically sends updates to a central aggregator (e.g., every few seconds). The aggregator computes the total and distributes adjusted limits back. This is the most common pattern for high-throughput systems.

Implementing a Multi-Region Rate Limiter

Below we implement a practical example using Python and Redis. The design uses a local token bucket per region and a global counter stored in a cross-region Redis cluster (assumed to be eventually consistent).

Local Token Bucket (Per Region)

import time
import threading

class LocalTokenBucket:
    def __init__(self, capacity, refill_rate, refill_period=1.0):
        self.capacity = capacity
        self.tokens = capacity
        self.refill_rate = refill_rate  # tokens per second
        self.refill_period = refill_period
        self.last_refill = time.monotonic()
        self.lock = threading.Lock()

    def _refill(self):
        now = time.monotonic()
        elapsed = now - self.last_refill
        if elapsed < self.refill_period:
            return
        added = int(elapsed * self.refill_rate)
        self.tokens = min(self.capacity, self.tokens + added)
        self.last_refill = now

    def try_consume(self, tokens=1):
        with self.lock:
            self._refill()
            if self.tokens >= tokens:
                self.tokens -= tokens
                return True
            return False

Each region runs this bucket with a capacity and refill rate equal to its share of the global limit (e.g., 40% of total requests per second).

Global Counter (Using Redis with Optimistic Locking)

import redis

class GlobalRateCounter:
    def __init__(self, redis_client, key, limit, window_seconds):
        self.r = redis_client
        self.key = key
        self.limit = limit
        self.window = window_seconds

    def allow_request(self, client_id, weight=1):
        """Returns True if request is under the global limit."""
        current_time = int(time.time())
        window_key = f"{self.key}:{current_time // self.window}"
        # Use WATCH/MULTI to atomically increment if under limit
        with self.r.pipeline(transaction=True) as pipe:
            while True:
                try:
                    pipe.watch(window_key)
                    current_count = int(pipe.get(window_key) or 0)
                    if current_count + weight > self.limit:
                        pipe.unwatch()
                        return False
                    pipe.multi()
                    pipe.incrby(window_key, weight)
                    pipe.expire(window_key, self.window * 2)
                    pipe.execute()
                    return True
                except redis.WatchError:
                    continue

This counter uses a sliding window approach (fixed window per time slot). In a multi-region setup, the Redis client should connect to a global Redis cluster (e.g., via a load balancer) or to a local replica that eventually propagates writes.

Combining Local and Global

The main request handler first checks the local bucket (fast, no network call). If the local bucket has tokens, it then checks the global counter (network call). If both pass, the request is allowed and the local bucket decrements.

import os

# Configuration per region
REGION_SHARE = 0.4  # 40% of global limit
GLOBAL_LIMIT = 1000  # requests per minute
LOCAL_CAPACITY = int(GLOBAL_LIMIT * REGION_SHARE)
LOCAL_REFILL_RATE = LOCAL_CAPACITY / 60.0

local_bucket = LocalTokenBucket(capacity=LOCAL_CAPACITY,
                                refill_rate=LOCAL_REFILL_RATE)

# Redis client pointing to global Redis cluster
redis_client = redis.Redis(host=os.environ['REDIS_HOST'],
                           port=6379, decode_responses=True)
global_counter = GlobalRateCounter(redis_client,
                                   key="global_rate_limit",
                                   limit=GLOBAL_LIMIT,
                                   window_seconds=60)

def handle_request(client_id):
    # 1. Check local bucket (fast, no network)
    if not local_bucket.try_consume():
        return 429, "Local rate limit exceeded"

    # 2. Check global counter (network call)
    if not global_counter.allow_request(client_id):
        # Refund local token? Optional: rollback local token
        # For simplicity, we don't refund here, but production code may.
        return 429, "Global rate limit exceeded"

    # 3. Process the request
    return 200, "OK"

Deploying Across Regions

In each region, deploy the same application with its own LocalTokenBucket. The global Redis cluster should be configured for cross-region replication. For example, with Redis Enterprise, use an Active-Active topology where each region writes to its local Redis and changes are synced asynchronously. With DynamoDB Global Tables, use the AWS SDK to perform conditional updates similar to the pipeline above.

Best Practices

Conclusion

Designing a rate limiter for multi-region deployment requires balancing performance, consistency, and fault tolerance. By combining a fast local token bucket with a globally synchronized counter, you can enforce strict global limits while keeping most requests low-latency. The example provided shows a practical implementation using Redis and Python, adaptable to cloud-native services like DynamoDB or Redis Enterprise. Remember to monitor sync delays, allocate regional capacity wisely, and always plan for failure modes. With these principles, your multi-region rate limiter will protect your backend from abuse while delivering a snappy user experience worldwide.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles