Session Storage with Redis
Web applications need a fast, shared place to store per-user session data — who’s logged in, their preferences, the items in their cart — that can be read and written on nearly every request without hammering the primary database. Redis is a natural fit for this job: it’s an in-memory key-value store with sub-millisecond latency, and, crucially for sessions, every key can carry an automatic expiration time, so abandoned sessions clean themselves up without any extra application code. This lesson covers how to model a session as a Redis key, exactly how expiration works under the hood, and the mistakes that turn a session store into a silent memory leak or a race condition.
Overview / How Session Storage Works in Redis
A “session” in this context is nothing more than a Redis key — typically named with a namespaced prefix like session:<sessionId>, where sessionId is a long, random, unguessable token generated at login and handed to the browser as a cookie. The value under that key holds whatever the application needs to remember about the user for the lifetime of the session. There are two common ways to store that value:
As a Hash — one field per piece of session data (user_id, username, role, cart_id, and so on). This lets the application read or update a single field with HGET/HSET without touching the rest of the session, and it avoids the cost of serializing and deserializing an entire blob on every read.
As a String — a single serialized blob, usually JSON, written with one SET and read with one GET. This is simpler when the application already has code that serializes the whole session object as a unit and doesn’t need to touch individual fields server-side.
Either way, the thing that makes Redis good at this job is expiration. Every key — regardless of its data type — can have exactly one TTL (time-to-live) attached to it. Redis does not scan the whole keyspace on a timer looking for expired keys; instead it combines two strategies:
Lazy expiration: whenever a key is accessed (by GET, HGETALL, or any other command), Redis first checks whether it has a TTL and whether that TTL has passed. If so, the key is deleted on the spot and the command behaves as if the key never existed.
Active expiration: a background cycle periodically samples a small number of keys that have a TTL set, deletes any that have already expired, and immediately samples again if a large share of that batch was expired. This means a session nobody ever reads again still gets freed from memory eventually, not just left to sit there.
Because Redis is single-threaded, every individual command — including an HSET writing several fields at once — runs to completion without any other command interleaving. That matters for sessions: writing several fields, or incrementing a login-attempt counter, can’t be corrupted by a concurrent request the way it could with separate read-modify-write steps done in application code.
A common session pattern is sliding expiration: instead of a fixed absolute deadline, the TTL is refreshed to its full value on every request, so the session expires N minutes after the user’s last activity rather than N minutes after login. That’s just a repeated call to EXPIRE on the session key.
Syntax
The commands you’ll use most often for session storage:
| Command | Description | Time Complexity |
|---|---|---|
HSET key field value [field value ...] |
Create or update one or more fields on a hash-based session | O(1) per field |
HGETALL key |
Read every field of a hash-based session | O(N), N = number of fields |
HGET key field |
Read a single field | O(1) |
SET key value [EX seconds] [KEEPTTL] |
Write a string-based session, optionally with an initial TTL, or keep the existing TTL | O(1) |
GET key |
Read a string-based session | O(1) |
EXPIRE key seconds |
Attach or refresh a TTL on any key, regardless of type | O(1) |
TTL key |
Seconds remaining before expiry (-1 = no TTL, -2 = key doesn’t exist) |
O(1) |
SCAN cursor MATCH pattern COUNT count |
Safely iterate matching keys without blocking the server | O(1) per call, O(N) over a full iteration |
As of Redis 7, EXPIRE also accepts an optional flag — NX (only set a TTL if the key has none), XX (only set if it already has one), or GT/LT (only set if the new TTL is greater/less than the current one) — useful if you want to guarantee a call only ever extends a session and never accidentally shortens it.
Examples
Example 1: A hash-based session with a TTL
HSET session:abc123 user_id 1001 username "ada" role "admin" created_at 1699999999
EXPIRE session:abc123 1800
HGETALL session:abc123
TTL session:abc123
Output:
(integer) 4
(integer) 1
1) "user_id"
2) "1001"
3) "username"
4) "ada"
5) "role"
6) "admin"
7) "created_at"
8) "1699999999"
(integer) 1800
HSET returns the number of new fields created (4). EXPIRE returns 1 to confirm the TTL was set. HGETALL returns the fields and values as a flat array in insertion order, and TTL confirms the session expires in 1800 seconds (30 minutes) unless it’s refreshed.
Example 2: A JSON string session with sliding expiration
SET session:xyz789 "{\"user_id\":42,\"role\":\"admin\"}" EX 1800
GET session:xyz789
TTL session:xyz789
EXPIRE session:xyz789 3600
TTL session:xyz789
Output:
OK
"{\"user_id\":42,\"role\":\"admin\"}"
(integer) 1800
(integer) 1
(integer) 3600
The session is written as a JSON blob with a 30-minute TTL in the same SET call. When the user makes another request, the application calls EXPIRE again with a fresh value — here simulating an active user, pushing the deadline out to a full hour from now instead of letting the original 30-minute clock run out.
Example 3: SET clearing a TTL vs. KEEPTTL
SET session:def456 "initial" EX 1800
TTL session:def456
SET session:def456 "updated" KEEPTTL
TTL session:def456
SET session:def456 "updated-again"
TTL session:def456
Output:
OK
(integer) 1800
OK
(integer) 1800
OK
(integer) -1
This is one of the sharpest edges in Redis session handling: a plain SET on an existing key wipes out its TTL — the key becomes permanent again unless you explicitly add KEEPTTL. The second SET above uses KEEPTTL and the TTL survives at 1800. The third SET omits it, and the TTL is gone (-1, meaning no expiration at all) even though the value only changed slightly.
How It Works Step by Step
When a user logs in, the application generates a random session ID, writes the session data under session:<id>, and calls EXPIRE (or SET ... EX) to attach a TTL. Redis stores the expiration as an absolute Unix timestamp internally, in a separate expiration dictionary that maps keys to their expiry time — the value itself is untouched.
On every subsequent request carrying that session ID in a cookie, the application reads the session (HGETALL or GET). Before returning any value, Redis checks the expiration dictionary: if the key’s expiry timestamp is in the past, Redis deletes the key immediately and responds as if it never existed — this is the “lazy” half of expiration. If the application wants sliding expiration, it issues an EXPIRE call right after the successful read, pushing the deadline forward.
Independently of any reads, a background cycle runs several times per second: it samples a batch of keys that have a TTL, deletes the ones that have already expired, and if a large share of the sample was expired, it samples again immediately. This is the “active” half — it guarantees that sessions nobody ever reads again still get freed from memory instead of lingering forever.
Because every command that touches a single key executes atomically on Redis’s single thread, there’s no window where two concurrent requests could see a half-written session or corrupt a hash mid-update — a real advantage over doing the equivalent read-modify-write across separate round trips at the application layer.
Common Mistakes
Mistake 1: Forgetting the TTL entirely. It’s easy to write a session key and forget the expiration, especially when adding fields incrementally with HSET, which never sets a TTL on its own.
SET session:leaky "some session data"
TTL session:leaky
Output:
OK
(integer) -1
A TTL of -1 means this key lives forever, silently consuming memory for every user who logs in and never logs out. Always pair session creation with an explicit EXPIRE or SET ... EX.
Mistake 2: Using KEYS to find sessions in production. KEYS session:* looks harmless in development, but it’s O(N) over the entire keyspace and, because Redis is single-threaded, it blocks every other command until it finishes — on a production instance with millions of keys, that’s a multi-second outage for every client. Use SCAN instead, which walks the keyspace incrementally with a cursor and never blocks the server for more than a fraction of a millisecond per call.
SET session:aaa "data1" EX 1800
SET session:bbb "data2" EX 1800
SET session:ccc "data3" EX 1800
SCAN 0 MATCH session:* COUNT 100
Output:
OK
OK
OK
1) "0"
2) 1) "session:aaa"
2) "session:bbb"
3) "session:ccc"
A cursor of "0" in the reply means the iteration is complete; on a larger keyspace you’d keep calling SCAN with the returned cursor until it comes back as 0 again.
Mistake 3: Reading a session with the wrong command for its type. Every Redis key has exactly one data type, fixed at creation. Calling a string command on a hash (or vice versa) doesn’t coerce the data — it errors.
HSET session:abc123 user_id 1001 username "ada"
GET session:abc123
Output:
(integer) 2
(error) WRONGTYPE Operation against a key holding the wrong kind of value
This typically happens when two parts of a codebase disagree on whether sessions are hashes or JSON strings. Pick one representation for session keys and stay consistent across the whole application.
Best Practices
- Always set a TTL the moment a session is created — never rely on remembering to add it later.
- Use a long, cryptographically random session ID (at least 128 bits of entropy) so it can’t be guessed or brute-forced.
- Namespace session keys consistently, e.g.
session:<id>, so they’re easy to pattern-match withSCANand easy to distinguish from other key types in the same database. - Implement sliding expiration by calling
EXPIREon every authenticated request if you want “expires N minutes after last activity” behavior. - Use
SCAN, neverKEYS, for any maintenance or monitoring operation that touches the session keyspace. - Choose a hash for session data you’ll update field-by-field, and a JSON string for data you always read and write as a whole unit.
- Remember that a plain
SETclears an existing TTL — useKEEPTTLif you’re updating a string session’s value without wanting to reset its expiration. - Don’t store highly sensitive data (raw passwords, full card numbers) in a session, even with a TTL — treat Redis as fast shared memory, not a secure vault.
- Pick a TTL that matches your product’s actual security and UX needs — a banking app session should expire far sooner than a “remember me for 30 days” e-commerce cart.
Practice Exercises
1. Create a session for user ID 5001 as a hash with fields user_id, email, and role, give it a 15-minute TTL, and confirm the TTL with TTL. Then simulate one page view by refreshing the TTL back to 15 minutes.
2. Build a “remember me” session: write it as a JSON string with SET ... EX using a 30-day TTL (in seconds), then update just the value later using KEEPTTL so the 30-day countdown isn’t reset.
3. Create three or four session keys under a shared prefix, then use SCAN with MATCH to list only the ones belonging to that prefix. Think through why this is safer than KEYS on a production dataset of 10 million sessions.
Summary
- A Redis session is just a namespaced key (
session:<id>) holding either a hash of fields or a serialized string, with a TTL attached. - Expiration combines lazy checks (on access) and active background sweeps, so expired sessions are freed even if never read again.
- TTL is per-key, not per-field —
HSETnever touches an existing TTL, but a plainSETon a string key clears it unless you useKEEPTTL. - Sliding expiration is just repeated
EXPIREcalls on every request. - Use
SCAN, notKEYS, to enumerate session keys in anything beyond a toy dataset. - Mixing data types on the same key path causes a
WRONGTYPEerror — standardize on hash or string per session.
