How to Build a Redis Rate Limiter for Your SaaS API
Most SaaS APIs die from success before they die from failure. One aggressive client, a runaway cron job, or a poorly written integration can saturate your backend before your monitoring even fires an alert. Rate limiting is the unsexy but critical layer that keeps your infrastructure honest — and understanding how to build it yourself means you can tune it precisely, not just accept whatever defaults a library ships with.
This guide implements a sliding-window rate limiter using Redis sorted sets, explains every design decision, and shows you how to configure it across multiple SaaS pricing tiers.
Why Redis, and Why Sorted Sets?
Redis is the standard choice for rate limiting because it is fast, atomic, and supports TTL-based key expiration natively. More importantly, its data structures let you model time-aware logic without a relational database.
Among Redis data types, sorted sets (ZSET) are ideal for sliding windows. Each member in a sorted set carries a score — and when that score is a Unix timestamp in milliseconds, you get a time-ordered log of every API request for free. Querying "how many requests happened in the last 60 seconds" becomes a range lookup by score, which Redis executes in O(log N) time.
Compare this to the simpler fixed-window approach, where you increment a counter per minute. Fixed windows are trivially exploitable: a client can send the full quota in the last second of window A and the full quota in the first second of window B, effectively doubling their allowed rate at the boundary. Sorted sets eliminate this class of abuse entirely.
The Core Algorithm
The sliding-window algorithm, expressed in plain steps:
- Define a key per client (e.g.,
rate:user:{userId}:{endpoint}). - On every request, record the current timestamp as a new member.
- Remove all members older than the window size.
- Count the remaining members.
- If the count exceeds the limit, reject the request. Otherwise, allow it and set the key's TTL.
All five steps must execute atomically to prevent race conditions under concurrent load. Redis Lua scripts give you that atomicity at zero extra infrastructure cost.
-- rate_limiter.lua
local key = KEYS[1]
local now = tonumber(ARGV[1]) -- current time in ms
local window = tonumber(ARGV[2]) -- window size in ms (e.g. 60000)
local limit = tonumber(ARGV[3]) -- max requests per window
local request_id = ARGV[4] -- unique ID for this request (e.g. UUID)
local window_start = now - window
-- 1. Remove timestamps outside the sliding window
redis.call("ZREMRANGEBYSCORE", key, "-inf", window_start)
-- 2. Count current requests in window
local count = redis.call("ZCARD", key)
if count >= limit then
return 0 -- rate limited
end
-- 3. Record this request
redis.call("ZADD", key, now, request_id)
-- 4. Set TTL so keys self-clean
redis.call("PEXPIRE", key, window)
return 1 -- allowed
Load this script once at server startup using SCRIPT LOAD and invoke it with EVALSHA on every request. This avoids network round-trips for script transfer and is the production-correct pattern.
Structuring Keys for Multi-Tier SaaS Plans
Flat rate limiting treats all customers equally — which is the opposite of how SaaS pricing works. You need limits that reflect plan entitlements without hardcoding thresholds into application logic.
A clean approach: store plan-level configuration in Redis hashes at startup, then look up the right limit before running the Lua script.
rate:config:free → { limit: 100, window: 60000 }
rate:config:pro → { limit: 1000, window: 60000 }
rate:config:enterprise → { limit: 10000, window: 60000 }
Your middleware fetches the user's plan tier from a session token or database, resolves the config key, then passes limit and window into the Lua script dynamically. This means changing plan thresholds requires updating a Redis hash, not a deployment.
For endpoint-level granularity — for example, capping /export at 10 calls per hour regardless of plan — append the route to the key and supply a separate config lookup:
rate:user:42:/export (10 per 3600000 ms)
rate:user:42:/search (500 per 60000 ms)
Surfacing Limits to API Consumers
Well-behaved APIs communicate rate limit state in response headers. Follow the emerging RateLimit header draft standard (which most major APIs now adopt):
RateLimit-Limit: 1000
RateLimit-Remaining: 847
RateLimit-Reset: 1720000860
Retry-After: 34 ← only on 429 responses
The Retry-After value is the number of seconds until the oldest request in the sorted set falls outside the window. You can calculate this by reading the lowest-scored member with ZRANGE key 0 0 WITHSCORES and computing (oldest_timestamp + window - now) / 1000.
Return HTTP 429 Too Many Requests on rejection. Never silently drop requests — dropping without a status code causes clients to hang and retry aggressively, making your problem worse.
Tuning Thresholds Without Guesswork
Picking limits by intuition leads to either over-permissive APIs or frustrated legitimate users. Instead:
- Instrument first. Log p95 and p99 request rates per user for two weeks before setting hard limits. Your free-tier power users will surprise you.
- Use burst allowances. A 1,000 requests/minute plan does not mean 1,000 requests exactly. Consider allowing a short burst — say, 150 requests in any 5-second window — to accommodate legitimate spikes like app startup sequences.
- Separate read and write limits. Write operations (POST, PUT, DELETE) are typically more expensive. Rate limit them independently, often at 10–20% of the read quota.
- Exempt health checks and internal services. Add an allowlist for your own IPs or a shared secret header to avoid accidentally throttling your own monitoring stack.
Handling Redis Failures Gracefully
Your rate limiter sits in the critical path. If Redis becomes unavailable and your code throws an unhandled exception, every API request fails. That is worse than having no rate limiter.
Implement a fail-open policy for non-security-critical APIs: if the Redis call times out or errors, allow the request and log the failure. For security-sensitive endpoints (authentication, payment initiation), fail-closed and return a 503 Service Unavailable.
Use connection pooling, set aggressive socket timeouts (250–500 ms is reasonable for LAN Redis), and monitor key metrics — evictions, command latency, and memory usage — as first-class production signals.
Why This Matters for Your Project
If you are building a SaaS product — whether a developer API, a mobile backend, or an internal platform — rate limiting is not optional infrastructure. It is what separates a product that scales gracefully from one that becomes a shared-tenancy liability the moment usage grows. Building this layer yourself, rather than wrapping an opaque library, means you understand exactly where the knobs are when a customer reports throttling, when you launch a new pricing tier, or when an attacker probes your endpoints. That understanding compounds over time into a more resilient, more trustworthy system.





