Redis Key Design and TTL Strategy
Key naming and expiry decide three things a limiter cannot recover from later: whether one identity’s traffic lands on one cluster slot, whether idle state cleans itself up, and whether a cache eviction can silently delete live counters. Getting them right takes ten minutes at design time and is expensive to change afterwards, which is why they belong beside the atomicity concerns in Redis counter architecture.
The problem in concrete numbers
An API with 2 million monthly active keys limits three route classes per key. Naive naming — ratelimit:{key}:{route}:{window} with no expiry — creates 6 million keys per window, none of which are ever deleted. At roughly 90 bytes per small key that is 540 MB per window; after a day of minute windows the instance holds 1,440 windows’ worth and runs out of memory, at which point the eviction policy decides which counters die.
With a TTL derived from the algorithm, the same workload holds only the keys for currently-active identities — typically a few per cent of the monthly total — and idle keys disappear on their own.
Key naming reference
| Component | Example | Why |
|---|---|---|
| Namespace prefix | rl: |
Lets you scan, flush, or migrate limiter keys without touching other data |
| Algorithm marker | rl:tb: / rl:gcra: |
Changing algorithm changes state format; the marker prevents reinterpretation |
| Version | rl:tb:v2: |
Changing capacity or window semantics needs a clean cutover |
| Identity, hash-tagged | {acct_42} |
Keeps every key for one identity in one cluster slot |
| Scope | :search |
Per-route or per-class limits |
| Window bucket | :28471234 |
Only for window algorithms; bucket index, not a timestamp |
Putting it together: rl:tb:v2:{acct_42}:search. The braces are meaningful — Redis Cluster hashes only the substring inside them, so rl:tb:v2:{acct_42}:search and rl:tb:v2:{acct_42}:reports land on the same node, which means a future script that needs both can run.
| Anti-pattern | Consequence |
|---|---|
| Raw email or token in the key | Personal data in memory dumps and slow-log output; hash it |
| No hash tag on a cluster | Cross-slot errors as soon as a script touches two of an identity’s keys |
| Timestamp in the key | Unbounded key growth: a new key per request rather than per window |
| Path with query string | Cardinality explosion; normalise to a route pattern |
| Client-supplied header value | Attacker mints unlimited keys, each with a fresh budget |
TTL derived from the algorithm
The right expiry is not a round number, it is a function of the limiter’s own recovery time.
| Algorithm | TTL | Reason |
|---|---|---|
| Token bucket | capacity / refill_rate + small grace |
Time to refill from empty to full: after that the state is indistinguishable from absent |
| GCRA | τ + T × cost |
The tolerance window plus one interval |
| Fixed window | 2 × window |
Survives late arrivals in the window without blocking the next |
| Sliding window (weighted) | 2 × window |
The previous window’s count is still needed |
| Sliding log | window + grace |
Entries older than the window are pruned anyway |
| Quota counter | Until the billing boundary | Must not vanish mid-period |
-- TTL always set from the algorithm, on every write, on both paths.
local cap = tonumber(ARGV[1])
local rate = tonumber(ARGV[2]) -- tokens per second
local cost = tonumber(ARGV[3]) or 1
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local st = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(st[1]) or cap
local ts = tonumber(st[2]) or now
tokens = math.min(cap, tokens + math.max(0, now - ts) / 1000 * rate)
local allowed = 0
if tokens >= cost then tokens = tokens - cost; allowed = 1 end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
-- Refill time from empty, plus a second of grace. Renewed on every request,
-- so an active key never expires and an idle one disappears on its own.
redis.call('PEXPIRE', KEYS[1], math.ceil(cap / rate * 1000) + 1000)
return { allowed, math.floor(tokens) }Step-by-step: designing the key space
// keys.ts — one place that builds every limiter key.
import { createHash } from "node:crypto";
const NS = "rl";
const ALGO = "tb";
const VERSION = "v2"; // bump when the stored shape or semantics change
/** Identities are hashed: no email, token, or IP lands in Redis in the clear. */
function identityHash(raw: string): string {
return createHash("sha256").update(raw).digest("base64url").slice(0, 22);
}
/** Route scope is a pattern, never a concrete path. */
export function limiterKey(identity: string, routePattern: string): string {
// The hash tag puts every key for one identity in one cluster slot.
return `${NS}:${ALGO}:${VERSION}:{${identityHash(identity)}}:${routePattern}`;
}
/** Window algorithms add a bucket index — an integer, not a timestamp. */
export function windowKey(identity: string, routePattern: string, windowSec: number, nowSec: number) {
const bucket = Math.floor(nowSec / windowSec);
return `${NS}:fw:${VERSION}:{${identityHash(identity)}}:${routePattern}:${windowSec}:${bucket}`;
}
/** TTL is a property of the algorithm, computed once and reused. */
export function bucketTtlMs(capacity: number, refillPerSec: number): number {
return Math.ceil((capacity / refillPerSec) * 1000) + 1_000;
}Gotchas and edge cases
allkeys-lruon a shared instance. Under memory pressure Redis will happily evict a live limiter key, which silently grants a client a fresh budget. Use a dedicated database withvolatile-ttl.- TTL set only on creation.
SET key value EX 60on first write and plainHSETafterwards means the key expires mid-use. Renew the expiry on every write. - Timestamps in key names.
rl:acct:1751899203780creates one key per request. The window bucket index is the correct granularity. - Unhashed identities. API keys and email addresses in key names leak into
MONITOR, the slow log, and memory dumps. Hash them. - Cardinality from paths. Keying by raw URL turns every unique query string into a key. Normalise to the route pattern the limit applies to.
- Trusting a client-supplied identity at an untrusted tier. An attacker rotating a header value mints an unlimited number of buckets, each with a full allowance.
- Version-free keys. Changing capacity or algorithm reinterprets existing state; without a version in the prefix you cannot cut over cleanly.
Verification and testing
# 1. Every limiter key must have a TTL. Sample the keyspace and prove it.
redis-cli --scan --pattern 'rl:*' --count 1000 | head -5000 | while read -r k; do
ttl=$(redis-cli pttl "$k")
[ "$ttl" -lt 0 ] && echo "NO TTL: $k"
done | head
# empty output = every sampled key expires
# 2. Cardinality: how many limiter keys exist, and how much memory do they use?
redis-cli info keyspace
redis-cli --memkeys --memkeys-samples 1000 2>/dev/null | grep -i 'rl:' | head -3
# Compare against your estimate of active identities x route classes.
# 3. Eviction policy on the limiter instance must not be allkeys-*.
redis-cli config get maxmemory-policy
# 1) "maxmemory-policy"
# 2) "volatile-ttl" <- correct; "allkeys-lru" silently deletes live counters
# 4. Cluster placement: all of one identity's keys should share a slot.
for r in search reports exports; do
redis-cli cluster keyslot "rl:tb:v2:{acct_42}:$r"
done | sort -u | wc -l
# expect 1Check one is worth running as a scheduled job. A single code path that forgets to set an expiry will not show up in tests — it shows up as an instance that runs out of memory three weeks later.
Frequently Asked Questions
What TTL should a limiter key have?
The time after which the stored state is indistinguishable from no state: a token bucket's refill-from-empty duration, GCRA's tolerance plus one interval, or twice the window for window counters. Renew it on every write so active keys never expire and idle ones disappear by themselves.
Should limiter keys share a Redis instance with the cache?
No. Caches are configured to evict under pressure, and an eviction policy that deletes any key will delete live counters — granting an unlimited budget precisely when the system is busiest. Use a dedicated instance or at minimum a dedicated database with an eviction policy that only touches keys with a TTL.
Do I need hash tags if I am not running a cluster?
Add them anyway. They are inert on a single instance and they are what makes a later migration to a cluster a configuration change rather than a rewrite of every key and every script.
How do I keep key cardinality under control?
Key by identity and a normalised route pattern, never by raw path, query string, or user agent. Where you must limit anonymous traffic, key by a truncated address prefix rather than the full address, and rely on TTLs so keys for one-off visitors vanish within a window.
Related
- Redis Counter Architecture — the parent guide on counter state and atomicity.
- Fail-Open vs Fail-Closed on Redis Outage — what happens when that store is unavailable.
- Redis Lua vs INCR for Rate Limiting — the command patterns these keys are used with.
- Cardinality Control for Rate Limit Metrics — the same cardinality discipline applied to metrics.