Five Things Redis Does That Aren't Caching
Redis gets adopted as a cache and stays one. Rate limiting, locks, sessions, streams and sorted sets are where the leverage actually is.
Redis is installed to cache database results, and then it sits there doing only that. The single-threaded execution model that makes it a good cache also makes it a correctness primitive — every command is atomic with respect to every other, which is exactly what you need for coordination between processes.
1. Rate limiting that does not have a cliff
The obvious implementation has a well-known flaw:
// fixed window: allow 100 per minute
const key = `rate:${userId}:${Math.floor(Date.now() / 60000)}`;
const n = await redis.incr(key);
if (n === 1) await redis.expire(key, 60);
if (n > 100) throw new TooManyRequests();A client sending 100 requests at 10:00:59 and 100 more at 10:01:01 makes 200 requests in two seconds and violates nothing, because the counter reset between them. The limit is enforced on paper and not in reality, which matters when the thing you are protecting is a downstream service that will fall over at 150.
A sliding window fixes it by keeping timestamps in a sorted set and counting only what is inside the window:
const now = Date.now(), windowMs = 60_000;
const r = await redis.multi()
.zremrangebyscore(key, 0, now - windowMs) // drop what aged out
.zadd(key, now, `${now}-${randomUUID()}`) // record this attempt
.zcard(key) // how many remain
.expire(key, Math.ceil(windowMs / 1000))
.exec();Exact, and it costs one round trip because MULTI pipelines the commands. The memory cost is one entry per request in the window, so for very high limits a token bucket — a counter plus a last-refill timestamp, refilled on read — is the cheaper choice. Either way, put the whole operation in a Lua script if you need strict atomicity under contention, since a transaction does not prevent interleaving of your own read-then-decide logic.
2. Distributed locks, and why the naive one is wrong
Two processes must not run the same job at once. The instinct:
if (await redis.setnx(key, "1")) {
await doWork();
await redis.del(key);
}Three bugs, each of which will happen:
- No expiry. The process crashes mid-work and the lock is held forever. Nothing runs that job again, ever, and there is no error to alert on.
- Expiry set separately is not atomic. Crash between
SETNXandEXPIREand you are back to the first bug. UseSET key val NX PX ttl, which does both in one command. - Deleting someone else's lock. Work takes longer than the TTL, the lock expires, another process acquires it, then the first process finishes and deletes the lock — which now belongs to the second. Two workers proceed believing they hold it.
The correct shape stores a unique token and releases conditionally:
const token = randomUUID();
const ok = await redis.set(key, token, "PX", 30_000, "NX");
if (!ok) return;
try {
await doWork();
} finally {
// release only if we still hold it — atomic compare-and-delete
await redis.eval(
`if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1]) else return 0 end`,
1, key, token
);
}The honest caveat: this is a lock for efficiency, not for safety. It prevents duplicate work in the common case. It does not guarantee mutual exclusion under a network partition or a long garbage-collection pause, because your process can lose the lock without knowing. If correctness genuinely depends on exclusivity, enforce it where the data is — a unique constraint or a conditional update in the database — and treat the Redis lock as an optimisation that stops most of the contention.
3. Sessions, which are underrated
Server-side sessions are often dismissed as not scalable, which has not been true for a long time. A session in Redis is a hash lookup on the request path — sub-millisecond, and trivially shared across every instance.
What you get back for that lookup is everything stateless tokens lose:
- Immediate revocation. Logging out deletes a key. Compare with a signed token, which stays valid until it expires no matter what you do.
- Permission changes take effect now, rather than whenever the token happens to be refreshed.
- Visibility. Every active session can be listed, shown to the user, and revoked individually — a feature every serious product ends up needing.
- Sliding expiry for free, by touching the TTL on each request.
The trade is a Redis dependency on the auth path, which means Redis going down means nobody is logged in. That is a real availability consideration and an argument for replication, not for pretending the stateless version has no costs.
4. Pub/Sub is fire-and-forget; Streams are not
These are routinely confused, and choosing wrong produces silent data loss.
Pub/Sub delivers to whoever is connected right now. A subscriber that is restarting misses everything published during that window, permanently, with no error. There is no history and no acknowledgement. It is correct for genuinely ephemeral signals — broadcasting a cache invalidation, pushing a live update to connected websockets — where a missed message is acceptable because a newer one follows.
Streams are an append-only log with consumer groups, acknowledgements and a pending-entries list. Messages survive disconnection, each is delivered to one consumer in a group, unacked messages can be claimed by another consumer after a timeout, and history is readable. That is a durable work queue with at-least-once semantics.
The rule: if losing a message would be a bug, you need Streams. If you find yourself adding acknowledgement or retry on top of Pub/Sub, you have started reimplementing Streams and should stop.
5. Sorted sets are a scheduler
The sorted set is the most versatile structure Redis has, and leaderboards are the least interesting use of it. The score is an arbitrary number, and when the score is a timestamp the structure becomes a priority queue over time.
// schedule work for later
await redis.zadd("jobs:scheduled", runAtMs, JSON.stringify(job));
// a worker claims everything now due, atomically
const due = await redis.zrangebyscore("jobs:scheduled", 0, Date.now(), "LIMIT", 0, 50);
if (due.length) await redis.zrem("jobs:scheduled", ...due);That is delayed jobs, retry with backoff — reschedule with a later score — and a due-date queue, in two commands. The same structure gives you a top-N list without sorting, time-window queries by score range, and rank lookups in logarithmic time.
One caution on the pattern above: read-then-remove is two commands and two workers can read the same entries. Wrap it in a Lua script if more than one worker will run, which is the whole reason you built it this way.
Operational notes that bite
- Set an eviction policy deliberately. The default refuses writes when memory fills rather than evicting. That is correct for a queue and catastrophic for a cache — and if the same instance holds both, you have to choose one behaviour for both. Use separate instances or separate databases for cache and for data you cannot lose.
- Never run
KEYSin production. It blocks the single thread while scanning the entire keyspace. UseSCAN, which is cursor-based and incremental. - One slow command stalls everything. Single-threaded means a large
ZRANGEor a Lua script with a loop delays every other client. Keep commands small; that is the deal. - Persistence is a choice you must make. Snapshotting can lose the window since the last save; the append-only file is more durable and slower. For a pure cache, neither matters. For sessions or a job queue, decide explicitly.
- Pipeline aggressively. Latency dominates. Fifty commands in one round trip is one network wait rather than fifty.
The short version
Fixed-window rate limits allow double the burst at the boundary; use a sliding window or a token bucket. Locks need atomic set-with-expiry, a unique token, and a conditional release — and are still only an optimisation, not a safety guarantee. Sessions in Redis buy revocation that tokens cannot. Pub/Sub loses messages by design and Streams do not. And a sorted set scored by timestamp is a scheduler you already have installed.