A session store and a redis rate limiter from the same primitives
With durability handled, Redis can hold state you would rather not put in the database on every request. A login session and a redis rate limiter are both short-lived state that a lot of requests touch and no request can afford to wait on. That profile fits Redis exactly: an in-memory hash with an expiry for the session, and an atomic counter with an expiry for the limiter.
This chapter builds both on a live Ubuntu 24.04 server, and the limiter ends by refusing the request that goes one over the line.
- A session is a hash keyed by a session ID, with an EXPIRE that you refresh on every request so an active user is never logged out mid-visit.
- A fixed-window rate limiter is one key per client per window: INCR counts the requests and EXPIRE clears the count when the window ends.
- INCR is atomic, so two requests arriving together each get a correct, distinct count with no lost increment and no lock.
- Setting the expiry only on the first request of a window keeps the window fixed rather than sliding it forward on every hit.
Both jobs share a reason for choosing Redis. Storing sessions in a relational table means a read and often a write on every single request, which is load your database does not need. A rate limiter in the database is worse, since it is a hot write row that every request contends on. Redis answers both from memory in microseconds and expires the keys for you.
We will build the session first, watch its TTL slide forward on access, then build the limiter and drive it past its cap. redis-cli plays the application so every step is visible.
Let's build.
Prerequisites
- Redis 7.0.15 running on your Ubuntu 24.04 LTS server, from part one.
- redis-cli on the box, and a shell to run a small loop.
- The hash commands from part one, since a session is just a hash with an expiry.
Store a session as a hash with a sliding TTL
A session holds a few fields, a user ID, a role, a CSRF token, under one opaque ID that lives in the user's cookie. A hash is the natural fit, and an EXPIRE gives it an idle timeout. The trick is to refresh that expiry on each request, so an active user's session slides forward while an abandoned one dies.
redis-cli HSET session:9f3ac1 user_id 1042 role editor csrf tok_7712 ip 203.0.113.7
redis-cli EXPIRE session:9f3ac1 300
redis-cli TTL session:9f3ac1
redis-cli HGETALL session:9f3ac1
redis-cli TTL session:9f3ac1
redis-cli EXPIRE session:9f3ac1 300
redis-cli TTL session:9f3ac1

HSET writes the session fields, EXPIRE gives it a five minute idle window, and the first TTL reads three hundred. A few seconds later the second TTL reads a little under three hundred, the window ticking down. The next request runs EXPIRE again, and TTL jumps back to three hundred. That refresh-on-access is the whole session lifecycle: idle out after five minutes of silence, stay alive while the user is active.
Build a redis rate limiter with INCR and EXPIRE
The fixed-window limiter is the simplest one that works. Choose a window, say sixty seconds, and a cap, say five requests. Key the counter by client and window. Every request runs INCR. The first request in a window also sets the expiry. If the count comes back above the cap, you reject the request.
LIMIT=5
KEY="rate:203.0.113.7:api"
for n in 1 2 3 4 5 6 7; do
count=$(redis-cli INCR "$KEY")
if [ "$count" = "1" ]; then redis-cli EXPIRE "$KEY" 60 >/dev/null; fi
if [ "$count" -le "$LIMIT" ]; then verdict="ALLOWED"; else verdict="429 BLOCKED"; fi
printf "request %s -> count=%s %s\n" "$n" "$count" "$verdict"
done
redis-cli TTL "$KEY"

Requests one through five come back ALLOWED, the count climbing with each INCR. Request six returns a count of six, over the cap, and the verdict flips to 429 BLOCKED, the HTTP status a real API would send. Request seven is blocked too. The final TTL shows the window has about sixty seconds left, after which the key expires, the count resets, and the client is allowed again. The expiry is set only when the count is one, so the window is anchored to the first request and does not creep forward.
That blocked sixth request is the second payoff of the series. A cache made the app faster; this makes it harder to abuse, and it cost two commands and no lock. A scraper or a runaway client hits the wall in memory, before your database or your application code ever sees the overflow.
Running the loop should print ALLOWED five times, then 429 BLOCKED for requests six and seven. If every request is allowed, the counter carried over from a previous run and never reset, so redis-cli DEL rate:203.0.113.7:api and run the loop again from a clean count.
Going further: why this is atomic without a lock
The reason this works under real concurrency is that INCR is a single atomic operation. Two requests that arrive at the same instant do not both read four and both write five. Redis serialises them, so one gets five and the other gets six, and the counts stay correct. You never take a lock, and there is no read-modify-write race, because the increment and the read are the same command.
Frequently asked questions
Why store sessions in Redis instead of a database or a JWT?
A database session means a read on every request against a hot table, which adds load you can avoid. A signed JWT needs no server store but cannot be revoked before it expires. A Redis session is fast to read, expires on its own, and can be deleted the instant a user logs out, which is why it is a common middle ground.
What is the difference between a fixed and a sliding window rate limiter?
A fixed window counts requests in discrete blocks of time and resets the count at each boundary, which is simple but allows a burst across the boundary. A sliding window tracks the timestamps of recent requests so the limit applies over any rolling interval, smoothing the burst at the cost of a little more state and logic.
Should the session TTL be an idle timeout or an absolute one?
Refreshing the TTL on each request, as we did here, gives an idle timeout that keeps active users signed in and drops abandoned sessions. For sensitive systems you may also want an absolute cap so no session lives past, say, twelve hours regardless of activity. You can enforce both by storing a created-at field and checking it on access.
Does the rate limiter counter need persistence?
Usually not. Rate-limit counters are short-lived and self-healing, since a lost counter only means a client briefly gets a fresh allowance, so most setups run the limiter without AOF durability. Sessions are different: if losing every logged-in session on a restart is unacceptable, enable the persistence from the previous chapter.
What you have, and what comes next
You built two pieces of production state from Redis primitives: a session as a hash with a sliding expiry that keeps active users logged in, and a fixed-window rate limiter that counts with INCR and blocked the sixth request live. Both lean on the same two ideas, an atomic operation and an expiry, that make Redis good at coordination and not just caching.
So far every value has sat still waiting to be read. The next chapter puts data in motion. Pub/sub fans a message out to whoever is listening, a list becomes a work queue that a worker drains, and Streams add durability when a fire-and-forget message is not enough. We will run all three and watch a message cross between two clients.