Karya Semi
HomeBlogSearchCategoriesAboutContact
Karya Semi

Less noise. More notes.

HomeBlogAboutContactPrivacy PolicyDisclaimer

© 2026 Karya Semi. All rights reserved.

XGitHubLinkedIn
  1. Home
  2. /Categories
  3. /Software Engineering

Designing Distributed Rate Limiters for Multi-Tenant SaaS

Implement a redis sliding window rate limiter distributed multi tenant race conditions fix to protect your SaaS APIs from traffic spikes and abuse.

Dian Rijal Asyrof/August 10, 2026/6 min read
Illustration for Designing Distributed Rate Limiters for Multi-Tenant SaaS

Rate limiting sounds simple until you try to do it across multiple Redis nodes, dozens of microservices, and tenants on different tiers. Then it becomes a coordination problem. The basic algorithm is about 5% of the work. The rest is making it correct under concurrency, cheap to run, and fair.

I've built a few of these. The biggest lesson is always the same: the textbook algorithms are a starting point, not a solution.

Let's start with what actually matters: picking the right algorithm for your use case, then implementing it in Redis without shooting yourself in the foot.

Token Bucket vs. Sliding Window - Pick Based on Traffic Shape

The token bucket algorithm gives each tenant a bucket that fills at a fixed rate up to a maximum capacity. Each request removes a token. No tokens means the request gets rejected (or queued, depending on your design). It's great when you want to allow short bursts but enforce a long-term average rate.

A tenant on the "Pro" plan might get a bucket capacity of 200 tokens, refilling at 20 tokens per second. They can burst 200 requests instantly, then they're throttled to 20/s. That burst tolerance is exactly what API consumers expect.

The sliding window log keeps a sorted set of timestamps for each request. To check the current rate, you count how many timestamps fall within the window. It's more precise, but it stores every single request timestamp in memory. At high throughput, that adds up fast.

There's a middle ground: the sliding window counter. You keep counters for the current window and the previous window, then interpolate. If your window is 60 seconds and you're 20 seconds into the current window, you estimate: previous_count * (40/60) + current_count. It's approximate, but the memory footprint is tiny compared to the log approach.

My recommendation: use token bucket for most API rate limiting. It handles bursty traffic gracefully and maps well to Redis operations. Use sliding window counter when you need strict "N requests per minute" semantics (billing systems, for example). Skip the sliding window log unless your throughput is low enough that memory doesn't matter.

Redis Implementation - Atomic Operations Are Non-Negotiable

Here's where most tutorials get lazy. They show you a basic GET → check → SET pattern and call it done. That pattern has a race condition so obvious it hurts.

# WRONG - race condition between GET and SET
current = redis.get(f"rate:{tenant_id}")
if int(current or 0) < limit:
    redis.incr(f"rate:{tenant_id}"))

Two concurrent requests both read current = 99, both see 99 < 100, and both increment. You just allowed 101 requests through. In a distributed system with multiple application servers hitting the same Redis instance, this happens constantly under load. If you've ever had to design API idempotency keys for distributed systems, you know how tricky these race conditions can be.

The fix is Lua scripting. Redis executes Lua scripts atomically - no other command runs while the script is executing. Here's a token bucket implementation that actually works:

- KEYS[1] = rate:{tenant_id}
- ARGV[1] = capacity, ARGV[2] = refill_rate, ARGV[3] = now_ms, ARGV[4] = requested
 
local key = KEYS[1]
local capacity = 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 capacity
local last_refill = tonumber(data[2]) or now
 
local elapsed = now - last_refill
local refill = elapsed * refill_rate / 1000
tokens = math.min(capacity, tokens + refill)
 
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(capacity / refill_rate) * 2)
 
return { allowed, math.floor(tokens) }

One round trip. One atomic operation. No race conditions. The bucket state (tokens remaining and last refill timestamp) lives in a single hash, and the script reads, computes, and writes in one shot.

For sliding window counter, the Lua script is even simpler:

local current_key = KEYS[1]
local previous_key = KEYS[2]
local limit = tonumber(ARGV[1])
local weight = tonumber(ARGV[2])
 
local current = tonumber(redis.call('GET', current_key) or 0)
local previous = tonumber(redis.call('GET', previous_key) or 0)
 
local estimated = previous * weight + current
 
if estimated < limit then
    redis.call('INCR', current_key)
    return { 1, math.floor(limit - estimated - 1) }
end
 
return { 0, 0 }

The weight parameter tells the script how much of the previous window to count. Your application calculates this based on where you are in the current window.

Multi-Tenant Isolation - Don't Let Noisy Neighbors Win

Here's the thing about shared infrastructure: one tenant hammering your rate limiter shouldn't degrade another tenant's experience. But it does if you're not careful.

Key design matters. Always namespace your rate limit keys by tenant:

rate:{plan}:{tenant_id}:{endpoint}

Not just rate:{tenant_id}. Different endpoints might have different costs. A POST /search with a complex query costs way more CPU than GET /health. Your rate limiter should reflect that.

Cost-based limiting is the move here. Instead of counting each request as 1, assign weights:

endpoint_costs = {
    "/api/search": 5,
    "/api/users": 1,
    "/api/bulk-import": 50,
}
 
cost = endpoint_costs.get(request.path, 1)
# pass cost as the 'requested' argument to the Lua script

A tenant doing 20 search queries burns through their budget 5x faster than someone doing simple reads. This is fair, and it protects your infrastructure from expensive operations that happen to be "just one request."

Per-tenant configuration should live in a fast cache (Redis hash or even a local in-memory cache with TTL). Hitting a database on every rate limit check adds 2-5ms of latency per request, which adds up. Store tenant limits as a hash:

HSET tenant:limits:{tenant_id} capacity 200 refill_rate 20

Your Lua script can pull these values in the same execution, or you pass them from the application layer if you want the script to stay simple.

Avoiding the CPU Trap - Lua Is Fast, But Respect It

Lua scripts in Redis block the entire server during execution. A script that takes 50ms means every other client waits 50ms. For a rate limiter script, that shouldn't happen - these are simple arithmetic operations. But I've seen people try to do logging, analytics, and cleanup inside their rate limit scripts. Don't.

Keep the Lua script under 1ms of execution time. If you need to log rate limit events (for dashboards, alerting, or billing), do it asynchronously after the script returns. Publish a message to a Redis stream or channel:

result = redis.evalsha(script_sha, 2, current_key, previous_key, limit, weight)
if not result[0]:
    redis.xadd("rate_limit_events", {
        "tenant": tenant_id,
        "endpoint": path,
        "timestamp": time.time()
    })

The event logging doesn't block the rate limit decision. You process the stream with a separate consumer that writes to your analytics store.

Connection pooling is another CPU-adjacent concern. If each request opens a new Redis connection, the overhead of TCP handshakes and AUTH commands eats into your latency budget. Use a connection pool. In Python with redis-py, the ConnectionPool handles this. In Go, go-redis does it by default. In Node, ioredis has built-in pooling.

Handling Redis Failures - Graceful Degradation

Your Redis instance will go down at some point. When it does, you have two choices: fail open (allow all traffic) or fail closed (reject all traffic). (When these outages inevitably happen, having a post-mortem process like an incident review template helps your team prevent them from happening again). For a SaaS platform, failing open is almost always the right call. You'd rather allow some extra requests than take down your entire API.

try:
    result = rate_limit_check(tenant_id)
except RedisConnectionError:
    logger.warning(f"Rate limiter unavailable, failing open for {tenant_id}")
    result = {"allowed": True, "remaining": -1}

Log the event, alert on it, but keep the API running. You can also run a Redis replica and fail over to it automatically. Redis Sentinel or Cluster handles this, but even a simple primary-replica setup with manual failover is better than a single point of failure.

For multi-region deployments, use local Redis instances per region with eventual consistency rather than a single global Redis. The latency of cross-region Redis calls (50-200ms) makes them unusable for rate limiting. Accept that rate limits might be slightly off between regions for a few seconds. In practice, this is fine - nobody's quota enforcement needs to be globally consistent to the millisecond.

What I Got Wrong Early On

The first rate limiter I built used per-second windows. Ten requests per second, checked with a simple counter and EXPIRE. It worked in testing. In production, tenants complained about intermittent 429s even when they were well under their limit. The problem was window boundaries. A tenant sending 8 requests at 11:00:59 and 8 at 11:01:01 was fine on average, but each individual window showed 8/10. With a burst at exactly the boundary, they'd hit 10/10 in one window and get rejected.

Switching to token bucket with a capacity that was 2-3x the per-second rate solved this completely. The burst tolerance absorbed boundary effects, and the refill rate enforced the long-term average. The tenants stopped complaining, and the limit actually felt fair.

Rate limiting in distributed systems is one of those problems where the gap between "it works on my laptop" and "it works in production at scale" is enormous. It's why when building critical infrastructure, developers choose tools they trust rather than the newest trend. But the building blocks are solid: Lua scripts for atomicity, token bucket for burst tolerance, cost-based weights for fairness, and graceful degradation for resilience. Get those four things right and you've got a rate limiter that can grow with your platform.

DR

Dian Rijal Asyrof

Writes about useful AI tools, programming practice, and the craft of building reliable software.

Previous articleDesigning a Micro-Payment Pipeline for AI Agents Using USDTNext articleTaming the Go Garbage Collector in Low-Latency Microservices
RedisDistributed SystemsRate LimitingSaas Architecture
On this page↓
  1. Token Bucket vs. Sliding Window - Pick Based on Traffic Shape
  2. Redis Implementation - Atomic Operations Are Non-Negotiable
  3. Multi-Tenant Isolation - Don't Let Noisy Neighbors Win
  4. Avoiding the CPU Trap - Lua Is Fast, But Respect It
  5. Handling Redis Failures - Graceful Degradation
  6. What I Got Wrong Early On

On this page

  1. Token Bucket vs. Sliding Window - Pick Based on Traffic Shape
  2. Redis Implementation - Atomic Operations Are Non-Negotiable
  3. Multi-Tenant Isolation - Don't Let Noisy Neighbors Win
  4. Avoiding the CPU Trap - Lua Is Fast, But Respect It
  5. Handling Redis Failures - Graceful Degradation
  6. What I Got Wrong Early On

See also

Illustration for Designing Resilient Connection Error Handling in Redis Task Queues
Web Development/Aug 7, 2026

Designing Resilient Connection Error Handling in Redis Task Queues

Learn how to build bulletproof connection retry and backoff strategies for Redis-based task queues in Node.js or Python without causing memory leaks.

7 min read
RedisWeb Development
Illustration for Postgres Transactions Are a Hidden Superpower for Distributed Systems
Software Engineering/Jul 3, 2026

Postgres Transactions Are a Hidden Superpower for Distributed Systems

Before reaching for Kafka, Redis, or a dedicated message queue, consider what Postgres can already do. The database most teams already use has surprisingly powerful coordination primitives built in.

3 min read
PostgresDatabase