Rate Limiting Your API: Strategies That Actually Scale
Every backend engineer has implemented rate limiting at least once. Most have done it wrong at least once too — not because the concept is hard, but because the gap between a working prototype and a production-grade system is wider than most tutorials let on.
Token buckets and leaky buckets are fine mental models. But when you are running a multi-tenant SaaS API under real traffic, the questions that actually break you are different: What happens when your Redis node fails mid-request? How do you prevent one power user from starving every other tenant? What does "graceful degradation" actually look like in code when burst traffic arrives?
This article answers those questions with patterns you can ship.
Why Redis Is the Right Tool — and Where It Betrays You
Redis is the de facto store for rate limit state. It is fast, atomic via Lua scripts, and supports TTL-based key expiry natively. The canonical sliding window counter in Redis looks something like this:
-- Sliding window rate limit via Lua (atomic execution)
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call("ZREMRANGEBYSCORE", key, 0, now - window)
local count = redis.call("ZCARD", key)
if count < limit then
redis.call("ZADD", key, now, now)
redis.call("EXPIRE", key, window / 1000)
return 1
else
return 0
end
This works beautifully on a single Redis node. It starts to lie to you on a Redis Cluster.
The Cluster Problem Nobody Warns You About
In Redis Cluster mode, keys are distributed across shards by their hash slot. If your rate limit keys land on different shards — which they will unless you use hash tags — you lose the ability to use multi-key operations atomically.
The fix is deliberate key design. Prefix all rate limit keys with a hash tag that forces co-location:
{tenant:acme}:endpoint:/v1/orders:window:60
The curly-brace portion is what Redis Cluster uses to determine the slot. Every key sharing the same tag lands on the same shard. This restores atomicity at the cost of potentially uneven shard distribution — an acceptable trade-off for correctness.
Additionally, Redis Cluster failover introduces a window of 10–30 seconds during leader election. You need a stance on what happens during that window: fail open (allow all requests) or fail closed (deny all requests). For most SaaS APIs, failing open is the safer choice. A brief surge of un-rate-limited traffic is recoverable. A brief complete outage is not.
Per-Tenant Fairness Is a Product Decision Masquerading as an Engineering One
Flat rate limits — 1,000 requests per minute for every tenant — are easy to implement and unfair by design. A customer on your Growth plan and a customer on your Enterprise plan share the same ceiling. Neither is happy.
Tiered Limits with a Single Code Path
Model your limits in configuration, not in code. Store per-plan limits in your database or a config service, and resolve them at request time:
- Free: 60 req/min, burst cap of 10
- Growth: 500 req/min, burst cap of 80
- Enterprise: Custom, negotiated per contract
Your middleware retrieves the tenant's plan at authentication time and injects the correct limit into the rate limiter. The limiter itself does not care about plans — it only sees a limit value and a key. This separation makes it trivial to update pricing tiers without touching rate-limiting logic.
Protecting Shared Tenants from Noisy Neighbours
Multi-tenant APIs have a classic problem: one tenant's traffic spike degrades response times for others. Rate limiting helps, but it is not enough on its own.
Complement your per-tenant limits with a global concurrency limit using a semaphore pattern in Redis. Track in-flight requests per tenant using INCR and DECR with a TTL safety net. If a tenant's concurrent request count exceeds a threshold — say, 50 simultaneous requests — queue or reject new ones immediately, regardless of their per-minute quota. This caps the blast radius of any single tenant on your infrastructure.
Graceful Degradation Under Burst Traffic
A rate limiter that simply returns 429 Too Many Requests with no additional guidance is a bad API. A production-grade limiter tells clients exactly how to behave.
Response Headers Are Part of the Contract
Every rate-limited response — whether allowed or denied — should include:
X-RateLimit-Limit: the ceiling for this windowX-RateLimit-Remaining: tokens left in the current windowX-RateLimit-Reset: Unix timestamp when the window resetsRetry-After: seconds to wait before retrying (on 429 responses only)
Clients that respect these headers can implement automatic back-off. Clients that ignore them will hammer your API regardless. Document this clearly, and consider building an SDK that handles back-off natively — it reduces support burden significantly.
Token Reservation for Priority Traffic
Not all requests are equal. A webhook delivery retry is more important than a dashboard refresh. Implement a reservation tier by partitioning your token bucket: reserve 20% of capacity for high-priority traffic classes, and only draw from the full bucket for standard requests. This is especially valuable when you are approaching your Redis throughput ceiling and need to ensure critical operations complete even under load.
Observability Is Not Optional
A rate limiter you cannot observe is a black box that will eventually confuse both your team and your customers. Instrument the following at minimum:
- Rate limit hit rate by tenant and endpoint — spikes here indicate client bugs or abuse
- Redis latency percentiles for limit check operations — p99 above 5ms is a warning sign
- Fail-open events — each one represents a period where your limits were not enforced
Feed these into your existing observability stack (Prometheus, Datadog, whatever you run) and set alerts before you need them.
Building the Right Abstraction
The most maintainable rate limiting systems share one trait: the limit enforcement logic is a thin, stateless middleware layer, while all policy decisions — which limits apply, what to do on breach, how to communicate quota state — live in configuration and business logic that engineers and product managers can reason about independently.
Resist the urge to build a bespoke rate limiting service from scratch unless your scale genuinely demands it. Libraries like redis-cell (a Redis module implementing GCRA) or battle-tested middleware like express-rate-limit backed by a Redis store handle the algorithm correctly. Your engineering time is better spent on the policy layer, the observability layer, and the graceful degradation contract with your API consumers.
Why This Matters for Your Project
Whether you are shipping a public API, a B2B SaaS platform, or an internal microservices mesh, rate limiting is load management, revenue protection, and reliability engineering rolled into one. Getting it right early — with per-tenant fairness, cluster-aware Redis patterns, and honest client communication — means your infrastructure scales with your customer base instead of against it. Build the policy correctly once, and your API can absorb growth without an emergency rewrite at 2 a.m.




