Sessions, rate limiting and leaderboards
Caching is what Redis is famous for, but a large share of real Redis usage is not caching at all. It is the jobs where you need a shared, fast, expiring, atomic store that every application instance can reach: sessions, rate limiting, leaderboards, and a few cousins. These are the things the relational database is a poor fit for, and Redis is a natural one.
Sessions — the classic non-cache use
A logged-in user has session state: who they are, their permissions, a cart. It must be readable on every request, shared across every server instance, and it should expire. That is Redis exactly.
HSET session:abc123 user_id 42 name "Kavita" role member → 3
EXPIRE session:abc123 1800 → 1 (30-minute idle timeout)
HGET session:abc123 user_id → "42"
TTL session:abc123 → 1800
The session id (abc123) lives in the user's cookie; the state lives in Redis under that key. Two
reasons this belongs in Redis and not PostgreSQL:
- It is read on every single request. A database hit per request for session data is pure
overhead; a Redis
HGETALLis microseconds. - It must expire on its own. A 30-minute idle timeout is one
EXPIRE, refreshed on each request. In PostgreSQL you would write a cleanup job to delete stale rows; Redis does it for you.
And why sessions in a shared store at all, rather than in one server's memory: the moment you run
more than one instance behind a load balancer, in-memory sessions break — request one lands on
server A, request two on server B, which has never heard of the user. A shared session store is
what makes horizontal scaling possible. This is also why a JWT (a self-contained signed token) is
the alternative — no server-side store — with its own trade-off: a JWT cannot be revoked before it
expires, whereas a Redis session is DELeted the instant you need to kick someone out.
Rate limiting — protecting everything behind it
"No more than 100 requests per minute per user." Redis is the standard tool, and there are two implementations worth knowing.
Fixed window — the simple one, an atomic counter with an expiry:
INCR rate:user42:<minute-bucket> → 1 (first request this minute)
EXPIRE rate:user42:<minute-bucket> 60 (set only when it is created)
INCR rate:user42:<minute-bucket> → 2
Key it by user and the current minute; INCR returns the count; over the limit, reject. The whole
check is one atomic increment. Its flaw is the boundary burst: 100 requests at 10:00:59 and
100 more at 10:01:00 is 200 in two seconds, yet each minute-bucket saw only 100. For many APIs that
is fine.
Sliding window — a sorted set, scored by timestamp, when the boundary burst matters:
ZADD rl:user42 <now> <unique-id> (record this request at its timestamp)
ZREMRANGEBYSCORE rl:user42 0 <now - 60> (drop everything older than the window)
ZCARD rl:user42 (count what remains; reject if over the limit)
The sorted set holds one entry per recent request; you trim the ones outside the rolling window and count what is left, so there is no boundary to game. More memory and more commands, but accurate.
The atomicity point, which is the real lesson. The check ("am I over the limit?") and the
increment must be one atomic operation, or two concurrent requests both read 99, both proceed,
and the limit is breached. INCR alone is atomic. But INCR then EXPIRE is two commands, and
if the process dies between them the key never expires. The correct version does both atomically in
a Lua script:
local c = redis.call('INCR', KEYS[1])
if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
return c
EVAL "<script>" 1 rate:user42 60 → 1 (first call: incremented and expiry set)
EVAL "<script>" 1 rate:user42 60 → 2
The whole script runs as one atomic unit on the server — no window where the count and the expiry disagree. When a rule needs several commands to hold together, a Lua script is the tool, and rate limiting is the canonical example.
Leaderboards — the sorted set's headline act
"Top 10 players", "your rank", "players near you" — the sorted set does all of it, live, as scores change:
ZADD scores 100 a 250 b 175 c 90 d → 4
ZREVRANK scores b → 0 (b is 1st, ranks are 0-based)
ZREVRANGE scores 0 2 WITHSCORES → b 250, c 175, a 100 (top 3)
ZRANGEBYSCORE scores 100 200 → a, c (everyone scoring 100–200)
ZINCRBY scores 50 a (add to a score atomically)
Every operation is fast even with millions of members, because the sorted set keeps them ordered
as you go. Trying to do "top 10 and this user's rank" in SQL means an ORDER BY over the whole
table and a COUNT of everyone above them, recomputed on every read — expensive and slow. Redis
maintains the order continuously, so the answer is always ready. This is the standard example of
Redis doing something the relational database genuinely cannot do cheaply.
The cousins, briefly
Same shape — shared, fast, atomic, expiring — different job:
- Distributed lock.
SET lock:resource <token> NX EX 10— set only if absent, with an auto-expiry so a crashed holder cannot deadlock everyone. The primitive behind "only one worker processes this at a time". (Correct distributed locking is subtle; for anything critical, read up on Redlock and its caveats rather than trusting a bareSET NX.) - Job queue.
RPUSHwork,BLPOPit from a worker that blocks until there is something. The basis of Sidekiq, RQ, Bull and Celery's Redis broker. - Real-time counters.
INCR page:views,PFADDinto a HyperLogLog for unique visitors in fixed tiny memory. Live metrics without hammering the database. - Pub/sub and streams.
PUBLISH/SUBSCRIBEfor fire-and-forget messaging; streams for a durable, replayable log with consumer groups when you need delivery guarantees.
The thread through all of these
Notice what sessions, rate limits, leaderboards, locks and queues share, and why none of them is a cache: they are shared mutable state that must be fast, atomic, and self-expiring. That is the job Redis is actually best at — arguably more than caching. A cache can be lost with no harm; a rate-limit counter or a distributed lock is doing real work, and it is why the durability lesson that closes this module matters even when you "only" use Redis for caching.
Check your work
Why sessions belong in Redis. Read on every request, shared across all instances, and they must expire on their own.
Why in-memory sessions break when you scale out. A second request can land on a different instance that has never seen the user; a shared store fixes it.
The trade-off of a JWT versus a Redis session. A JWT needs no server store but cannot be revoked before it expires; a Redis session can be deleted instantly.
Fixed-window rate limiting and its flaw. An INCR per time bucket; the boundary burst lets 2×
the limit through across a boundary.
Sliding-window rate limiting. A sorted set scored by timestamp, trimmed to the window and counted — accurate, at more cost.
Why the rate-limit check must be atomic. Two concurrent requests can both read 99 and both proceed; a Lua script makes check-and-increment one operation.
The INCR-then-EXPIRE bug. Two commands; a crash between them leaves the key without an
expiry — fix with a Lua script.
Why a leaderboard is a sorted set, not SQL. Redis keeps members ordered as scores change, so
rank and top-N are instant; SQL recomputes an ORDER BY and a COUNT each read.
The distributed-lock primitive and its caveat. SET ... NX EX; correct distributed locking is
subtle (Redlock), so do not trust a bare SET NX for anything critical.
The property all these uses share. Shared mutable state that must be fast, atomic, and self-expiring — which is why they are not caching.
Practice
- Store a session as a hash with a TTL. Read a field, then refresh the TTL as you would per request.
- Delete a session and confirm the user is "logged out" instantly — the thing a JWT cannot do.
- Build a fixed-window rate limiter with
INCR+EXPIRE. Exceed the limit and reject. - Construct the boundary-burst: fire the limit at the end of one window and the start of the next.
- Rebuild it as a sliding window with a sorted set and confirm the boundary burst is now caught.
- Reproduce the
INCR-then-EXPIRErace: kill the client between the two and confirm the key never expires. - Fix it with the Lua script and confirm the expiry is always set.
- Build a leaderboard: add scores, read the top 3, find one member's rank, and bump a score.
- Write the SQL for "top 10 and this user's rank" and reason about its cost as the table grows.
- Implement a distributed lock with
SET ... NX EX. Have two workers contend and confirm only one wins. Then reason about what happens if the winner crashes. - Build a job queue with
RPUSH/BLPOPand process items from a second shell.
Official documentation
- Redis — Sessions and other patterns — The non-cache use cases, from the source.
- Redis — Rate limiting — Fixed and sliding windows.
- Redis — EVAL and Lua scripting — Atomic multi-command operations.
- Redis — Sorted sets — The leaderboard engine.
- Redis — Distributed locks (Redlock) — And why correct locking is harder than
SET NX. - Redis — Streams — Durable, replayable messaging with consumer groups.
Next: TTLs, eviction policies, and running out of memory.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship