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 count with and without an algorithm-derived expiry Without expiry, keys accumulate every window until memory is exhausted and eviction begins deleting live counters; with a TTL sized from the algorithm, the key count settles at the number of currently active identities. Key count over a day no TTL: grows until eviction starts TTL from the algorithm: flat at active identities eviction deletes live counters here

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 in cluster mode 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
lua
-- 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

typescript
// 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;
}
Cluster placement with and without a hash tag Without a hash tag an identity's route keys hash to different slots on different nodes, so a script touching two of them is rejected; with the identity inside braces every key for that identity shares a slot. Where the braces go decides where the keys go no hash tag rl:acct_42:search → node 1 rl:acct_42:reports → node 3 a two-key script is rejected CROSSSLOT at the worst moment identity in braces rl:{acct_42}:search → node 2 rl:{acct_42}:reports → node 2 multi-key scripts work one identity, one node changing this later means migrating every key — decide before launch What each eviction policy does to live limiter keys An all-keys eviction policy deletes live counters under memory pressure and grants unlimited budgets, volatile-ttl removes only expiring keys nearest their expiry, and noeviction rejects writes so the limiter fails loudly instead of silently. Memory pressure meets the eviction policy allkeys-lru deletes live counters clients get a fresh budget silent, at peak load volatile-ttl removes keys nearest expiry state that was nearly stale the sane default noeviction writes are refused limiter fails loudly pairs with a degradation policy a limiter that loses keys silently is worse than one that reports it cannot decide

Gotchas and edge cases

  • allkeys-lru on 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 with volatile-ttl.
  • TTL set only on creation. SET key value EX 60 on first write and plain HSET afterwards means the key expires mid-use. Renew the expiry on every write.
  • Timestamps in key names. rl:acct:1751899203780 creates 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

bash
# 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 1

Check 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 Redis Cluster?

Add them anyway. They are inert on a single instance and they are what makes a later migration to Redis 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.