The Quest Begins (The "Why")
Picture this: I’m sipping coffee, staring at a screen full of latency spikes, and the product manager keeps asking, “Why does our API choke whenever traffic spikes?” We’d just shipped a shiny new feature, and the monolith was groaning under the load. Honestly, it felt like we were trying to fill a bathtub with a teaspoon while the faucet was wide open.
I remembered a similar scene from Monty Python and the Holy Grail — King Arthur’s knights shouting, “It’s just a flesh wound!” as they kept charging forward despite obvious damage. Our monolith was that knight: stubborn, bruised, but still marching on. The real question wasn’t “Do we need more power?” It was “Where should we put the brakes?”
That’s when the idea of a rate limiter popped into my head. If we could control how many requests each user (or IP) could hammer our service with, we’d smooth out those spikes and protect downstream systems. But should the limiter live inside the monolith, or should we break it out as its own microservice? The answer turned out to be less about architecture and more about a single, critical insight: state sharing.
The Revelation (The Insight)
In a monolith, all your code runs in one process (or a few identical instances behind a load balancer). That means you can keep rate‑limiting state in memory — think a simple token bucket or a sliding window counter — and every request sees the same data because it’s the same process. Life is easy.
When you split the system into microservices, each service runs in its own process, possibly on different hosts, scaled independently. Now an in‑memory limiter is useless: two instances of the same service will each think they’ve only seen half the traffic, and the limit is effectively doubled. Worse, if you scale out to ten instances, you’ve accidentally allowed ten times the intended throughput. The system starts behaving like a leaky bucket with a hole the size of a fire hose.
The “holy grail” insight? Centralize the state that needs to be shared across service boundaries. For a rate limiter, that means moving the counter out of the process and into a fast, shared datastore — Redis being the classic choice. The limiter becomes a thin service (or a library that talks to Redis) that all instances consult before letting a request through.
Why does this beat the alternatives?
- Per‑instance limits are simple but lie to you under scale.
- Centralized API gateway limits work, but they couple your business logic to infrastructure and make it hard to have per‑service or per‑endpoint policies.
- Ad‑hoc solutions (like using a database table for counters) introduce unacceptable latency and defeat the purpose of a lightweight guardrail.
By sticking to a shared, atomic counter in Redis, we get:
- Exact limits regardless of how many instances we run.
- Low latency (sub‑millisecond Redis gets are lightning fast).
- Operational simplicity (one place to tune, monitor, and debug).
Let’s see the magic in code.
Wielding the Power (Code & Examples)
The Struggle: Naive In‑Memory Limiter (Monolith‑Friendly)
# monolith_limiter.py
import time
from collections import defaultdict
# Simple token bucket per key (e.g., user_id or IP)
_buckets = defaultdict(lambda: {"tokens": 10, "last": time.time()})
REFILL_RATE = 5 # tokens per second
CAPACITY = 10
def allow_request(key: str) -> bool:
now = time.time()
bucket = _buckets[key]
# Refill tokens based on elapsed time
elapsed = now - bucket["last"]
bucket["tokens"] = min(CAPACITY, bucket["tokens"] + elapsed * REFILL_RATE)
bucket["last"] = now
if bucket["tokens"] >= 1:
bucket["tokens"] -= 1
return True
return False
What’s wrong here?
If we run two copies of this service behind a load balancer, each gets its own _buckets dictionary. User A could hit 10 requests on instance 1 and another 10 on instance 2, blowing past the intended limit of 10 per second. The bug hides in plain sight until you see traffic spikes that shouldn’t exist.
The Victory: Redis‑Backed Token Bucket (Microservice‑Ready)
We’ll use Redis’ atomic EVAL to run a Lua script that implements the token bucket in one round‑trip. This guarantees correctness even with thousands of concurrent requests.
# redis_limiter.py
import redis
import time
r = redis.Redis(host='redis-cache', port=6379, db=0)
# Lua script: returns 1 if allowed, 0 if denied
LUA_SCRIPT = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local bucket = redis.call('HMGET', key, 'tokens', 'last')
local tokens = tonumber(bucket[1]) or capacity
local last = tonumber(bucket[2]) or now
-- refill
local delta = math.max(0, now - last)
tokens = math.min(capacity, tokens + delta * refill_rate)
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'last', now)
redis.call('EXPIRE', key, 3600) -- auto‑clean after 1h of inactivity
return allowed
"""
def allow_request(key: str, capacity: int = 10, refill_rate: float = 5.0) -> bool:
now = time.time()
allowed = r.eval(LUA_SCRIPT, 1, key, capacity, refill_rate, now)
return bool(allowed)
Why this works:
- The Lua script runs atomically inside Redis, so no race condition between reading and writing the bucket.
- All service instances talk to the same Redis cluster, guaranteeing a single source of truth.
- The
EXPIREcall keeps memory usage tidy—unused keys vanish after an hour of silence.
Common Traps to Avoid
| Trap | What Happens | How to Dodge |
|---|---|---|
| Using separate Redis keys per instance (e.g., appending host IP) | You end up with per‑instance limits again. | Keep the key purely based on the entity you’re limiting (user_id, API key, IP). |
| Ignoring clock drift | If service clocks differ wildly, refill calculations become off. | Use Redis’ TIME command to get a unified timestamp, or rely on NTP‑synced nodes (still good practice). |
| Blocking on Redis latency | A slow Redis can turn your limiter into a bottleneck. | Deploy Redis close to your services, use connection pooling, and consider a fallback to a local counter with a low‑risk “burst‑only” mode for extreme cases. |
| Over‑complicating the script | Adding unnecessary fields makes the Lua script harder to audit and slower. | Stick to the minimal fields needed: tokens and last timestamp. |
ASCII Diagram: Where the Limiter Lives
Monolith (single process) Microservices (many instances)
+-------------------+ +-------------------+
| HTTP Server | | HTTP Svc A |
| +------------+ | | +------------+ |
| | Limiter (in‑mem) | <---shared mem----> | | Limiter (Redis) |<---+
| +------------+ | | +------------+ |
| +------------+ | | +------------+ |
| | Business Logic| | | Business Logic| |
| +------------+ | | +------------+ |
+-------------------+ +-------------------+
^ ^ ^
| | |
+------+ | +------+
| |
Redis Cluster (shared state)
In the monolith, the limiter lives in‑process (simple, but doesn’t scale out). In the microservice world, the limiter becomes a thin client that talks to a shared Redis store—exactly the pattern we just coded.
Why This New Power Matters
Now that we’ve centralized the rate‑limiting state, we can:
- Scale fearlessly: Spin up ten, a hundred, or a thousand instances of your service without worrying about the limit multiplying.
-
Policy‑per‑service: Want a stricter limit on the payment endpoint and a looser one on the public blog? Just pass different
capacity/refill_ratearguments to the same limiter call. -
Observability built‑in: Redis gives you
INFO,MONITOR, and latency metrics for free; you can graph how close you are to hitting the ceiling. - Resilience: If the limiter service (Redis) goes down, you can fail open or closed based on your business needs—explicit, not accidental.
The change didn’t require rewriting our business logic; we merely swapped out a tiny, well‑encapsulated module. It felt like finding a hidden lever in a dungeon that suddenly opened a treasure chest full of stability.
Your Turn: Embark on Your Own Quest
Here’s a challenge for you: pick one piece of state in your system that currently lives in-process (a cache, a counter, a session store) and ask yourself: If I doubled the number of service instances tomorrow, would this still be correct? If the answer is “no,” sketch out how you’d move that state to a shared store (Redis, DynamoDB, Cassandra—whatever fits your stack). Write a tiny proof‑of‑concept, run a load test, and watch the chaos turn into calm.
What will you centralize first? Drop your thoughts in the comments—I’d love to hear about your own epic loot grabs! 🚀
Top comments (0)