A single Redis key per tenant becomes a hotspot when that tenant is your largest customer—or when everyone shares one global key. On Redis Cluster you also cannot multi-key Lua across slots casually. Design buckets that shard by (tenant, route, shard), enforce fairness with per-tenant ceilings, and keep Lua atomic within one slot.
⚡ TL;DR: Key =
{tenantId}:rl:{route}:{bucketShard}; hash-tag tenant for locality; atomic token bucket via Lua; independent limits per route; avoid one key for the whole fleet. Complements Lambda Reserved Concurrency: Bulkheads That Protect Tenant Workloads and Multi-Tenant Coding Assistants: Isolated ECS Fargate Spot Runtimes.
Token bucket Lua (single slot)
// rateLimit.js — Node 20
import Redis from "ioredis";
const redis = new Redis.Cluster([{ host: process.env.REDIS_HOST }]);
// KEYS[1] bucket key (must include hash tag)
// ARGV: capacity, refill_per_ms, now_ms, cost
const BUCKET = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
local delta = math.max(0, now - ts)
tokens = math.min(capacity, tokens + delta * refill)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, 60000)
return {allowed, tokens}
`;
export async function takeToken({ tenantId, route, capacity, rps }) {
const shard = hashShard(tenantId, 16); // optional spread for huge tenants
const key = `{${tenantId}}:rl:${route}:${shard}`;
const refillPerMs = rps / 1000;
const [allowed] = await redis.eval(
BUCKET, 1, key, capacity, refillPerMs, Date.now(), 1
);
return allowed === 1;
}
function hashShard(id, n) {
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
return h % n;
}
// ❌ Hot key — every request in the company
await redis.decr("global:rate");
Fairness under bursty tenants
- Per-tenant capacity separate from per-route capacity (both must pass).
- Soft limit then hard limit with 429 +
Retry-After. - Optional global cell limit to protect shared downstreams (bulkheads).
export async function authorize(tenantId, route) {
const tenantOk = await takeToken({
tenantId, route: "_tenant", capacity: 500, rps: 100,
});
if (!tenantOk) return { ok: false, reason: "tenant_limit" };
const routeOk = await takeToken({
tenantId, route, capacity: 50, rps: 20,
});
if (!routeOk) return { ok: false, reason: "route_limit" };
return { ok: true };
}
Cluster and hash tags
{tenantId} keeps all that tenant’s bucket keys on one slot—good for locality, bad if one tenant is enormous (then shard suffix). Never attempt a single Lua script across keys in different slots without a Cluster-aware design.
Observability
Emit rate_limit.allow / rate_limit.deny with tenant, route, reason. Alert on deny spikes and on Redis hot-key CPU. Load-test the largest tenant’s key; if Redis node CPU pegs, increase shard count.
Closing checklist
✅ Dos
– ✅ Hash-tag keys for Cluster-safe Lua
– ✅ Shard buckets for mega-tenants
– ✅ Stack tenant + route (+ cell) limits
– ✅ Return Retry-After on 429
– ✅ Meter denies per tenant for abuse detection
❌ Don’ts
– ❌ Don’t use one global counter key
– ❌ Don’t multi-key Lua across slots
– ❌ Don’t silently drop requests without metrics
– ❌ Don’t set infinite TTL on bucket hashes
– ❌ Don’t rely on Redis alone for billing-grade quotas without durable logs
Related reading
- Lambda Reserved Concurrency: Bulkheads That Protect Tenant Workloads
- Multi-Tenant Coding Assistants: Isolated ECS Fargate Spot Runtimes
- Express.js Best Practices: Build Production-Ready APIs
- System Design Interview Cheat Sheet
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
