RizTech Academy logo
RizTech Academy
Key-Value Stores and CachingLesson 4 of 635 min

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 HGETALL is 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 bare SET NX.)
  • Job queue. RPUSH work, BLPOP it 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, PFADD into a HyperLogLog for unique visitors in fixed tiny memory. Live metrics without hammering the database.
  • Pub/sub and streams. PUBLISH/SUBSCRIBE for 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

  1. Store a session as a hash with a TTL. Read a field, then refresh the TTL as you would per request.
  2. Delete a session and confirm the user is "logged out" instantly — the thing a JWT cannot do.
  3. Build a fixed-window rate limiter with INCR + EXPIRE. Exceed the limit and reject.
  4. Construct the boundary-burst: fire the limit at the end of one window and the start of the next.
  5. Rebuild it as a sliding window with a sorted set and confirm the boundary burst is now caught.
  6. Reproduce the INCR-then-EXPIRE race: kill the client between the two and confirm the key never expires.
  7. Fix it with the Lua script and confirm the expiry is always set.
  8. Build a leaderboard: add scores, read the top 3, find one member's rank, and bump a score.
  9. Write the SQL for "top 10 and this user's rank" and reason about its cost as the table grows.
  10. 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.
  11. Build a job queue with RPUSH/BLPOP and process items from a second shell.

Official documentation

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