The Quest Begins (The "Why")
I was building a tiny micro‑service that served cat pictures to a hungry frontend. Everything was fine until the traffic spiked during a meme‑storm and the API started returning 500s because we were hammering the downstream image service. The obvious fix? slap a rate limiter in front of the endpoint.
I opened my editor, typed up a quick Redis‑based counter, deployed, and felt like a hero. Then the network between our app nodes and the Redis cluster hiccuped for a couple of seconds. Suddenly every request started getting rejected — even the legit ones that were well under the limit. My “heroic” limiter had turned into a brick wall.
That moment forced me to ask: why did a tool meant to protect availability end up killing it? The answer was hiding in a concept I’d only seen in textbooks: the CAP theorem.
The Revelation (The Insight)
The CAP theorem says that in a distributed system you can only guarantee two out of three properties:
- Consistency – every node sees the same data at the same time.
- Availability – every request gets a response (success or failure) without waiting.
- Partition tolerance – the system keeps working even when network splits occur.
You can’t have all three; when a partition happens you must choose between C and A.
For a rate limiter the choice is subtle. If you insist on a strict counter (every increment is instantly visible everywhere) you’re aiming for Consistency + Partition tolerance (CP). When the partition hits, the node can’t talk to Redis, so it can’t guarantee the count is accurate — it either blocks requests (fails closed) or lets everything through (fails open). Either way, availability suffers.
If you relax consistency a bit and allow the count to be eventually accurate, you can stay Available + Partition tolerant (AP). The limiter will still throttle most traffic, and during a brief network glitch it will just be a little looser — which is usually far better than a total outage.
That was the “aha!” moment: a rate limiter doesn’t need perfect, instant counts to be useful. Approximate counters give you the same protection while keeping the service alive when the network gets moody.
Wielding the Power (Code & Examples)
The Struggle: a Strict CP Limiter
Here’s the first version I wrote (Node.js + ioredis). It looks innocent, but it’s the CP trap.
// strict-rate-limiter.js
const Redis = require('ioredis');
const redis = new Redis({ host: 'redis-cluster', port: 6379 });
const LIMIT = 100; // requests per minute
const WINDOW = 60; // seconds
async function allow(ip) {
const key = `rl:${ip}`;
// INCR returns the new value after increment
const count = await redis.incr(key);
if (count === 1) {
// first hit in this window – set expiry
await redis.expire(key, WINDOW);
}
return count <= LIMIT; // true if allowed
}
What went wrong?
- If Redis is unreachable,
incrthrows → we catch nothing and the request fails (500). - Even if we catch the error and decide to “fail open”, we’ve just thrown away any protection during a partition.
- The limiter becomes a single point of failure – exactly what we tried to avoid.
The Victory: an AP Approximate Limiter
The trick is to use a sliding window counter that can be updated locally and only periodically synced to Redis. The local copy gives us instant responses; the occasional sync keeps the global view from drifting too far.
// approx-rate-limiter.js
const Redis = require('ioredis');
const redis = new Redis({ host: 'redis-cluster', port: 6379 });
const LIMIT = 100;
const WINDOW = 60 * 1000; // ms
const SYNC_INTERVAL = 5000; // ms – how often we push local counts to Redis
// In‑memory buckets per IP (could be a Map or LRU cache)
const buckets = new Map();
function getBucket(ip) {
let bucket = buckets.get(ip);
if (!bucket) {
bucket = { count: 0, windowStart: Date.now() };
buckets.set(ip, bucket);
}
return bucket;
}
async def allow(ip):
bucket = getBucket(ip)
now = Date.now()
# reset if we've slid past the window
if now - bucket.windowStart > WINDOW:
bucket.count = 0
bucket.windowStart = now
if bucket.count < LIMIT:
bucket.count += 1
# fire‑and‑forget sync – we don't await here to keep latency low
syncBucketAsync(ip)
return True
return False
async def syncBucketAsync(ip):
try:
bucket = buckets.get(ip)
if bucket and bucket.count > 0:
# Use INCRBY to add the local delta atomically
await redis.incrby(f"rl:{ip}", bucket.count)
# Reset local counter after sending
bucket.count = 0
except Exception as e:
# Swallow errors – we stay available; the next sync will try again
pass
Why this works better:
-
Availability – the function only touches the local
Map. Even if Redis is down, we still decide allow/deny based on the most recent count we have. - Partition tolerance – network splits don’t block the decision path.
- Eventual consistency – every few seconds we push the accumulated delta to Redis. If a sync fails we simply retry later; the global count may be a tad low or high, but it never drifts wildly because we reset the local bucket after each successful sync.
Common Pitfalls (the “traps”)
- Forgetting to reset the local bucket after a sync – you’ll double‑count and accidentally become more restrictive than intended.
- Using a synchronous Redis call inside the hot path – that brings us back to CP behavior and adds latency.
- Letting the in‑memory map grow unbounded – always cap it (LRU or TTL) or you’ll OOM under high cardinality IPs.
A quick test: spin up two Redis instances, cut the network between them, and hammer the limiter with fake IPs. The strict version will start returning 500s as soon as the link drops; the approximate version will keep serving traffic, only letting a slightly higher burst through until the partition heals.
Why This New Power Matters
Now you’ve got a limiter that doesn’t take your whole service hostage when the network gets moody. You can keep API gateways, auth services, or any public endpoint alive while still protecting downstream systems from thundering herds.
The insight transfers beyond rate limiting: whenever you’re designing a distributed component that needs to make fast decisions (caching, leader election, load‑shedding), ask yourself: Do I really need strict consistency right now? If the answer is “no”, lean into AP, accept a little fuzziness, and gain resilience.
Give it a try: replace that rigid counter in your side‑project with an approximate sliding window, simulate a Redis partition with tc or Docker’s network pause, and watch the error rate stay flat while the request latency stays smooth.
Challenge: Build a tiny Go or Python service that logs the number of times the limiter had to “guess” during a simulated partition. Share your numbers in the comments – I’d love to see how different sync intervals affect the trade‑off.
Until next time, keep your systems available and your queries limited — just like a Jedi keeping the Force in balance.
May your rate limits be ever in your favor.
Top comments (0)