Storing sessions as Hashes vs JSON strings, sliding expiry with EXPIRE on each request, session invalidation and logout, multi-device session tracking, and the consistency trade-offs when reading sessions from replicas.
P-9 — Session Management Patterns
Who this module is for: You store user sessions in Redis — or are planning to — and want to implement them correctly. This covers session structure (Hash vs JSON string), sliding expiry, concurrent device management, and the read-from-replica consistency trap that silently logs users out.
Why Redis for Sessions
HTTP is stateless. Sessions provide continuity: a session token in a cookie maps to server-side state (user ID, permissions, preferences). The session store must be:
- Fast — checked on every request
- Shared — accessible from every application server instance
- Expirable — sessions must auto-expire when idle or after a fixed duration
Redis satisfies all three. A 1ms session lookup is imperceptible. Every application server connects to the same Redis instance. TTL handles expiry automatically.
The alternative — database-backed sessions (PostgreSQL, MySQL) — works but adds a database query to every request, and relational databases are optimized for complex queries, not millions of simple ID lookups.
Session Structure: Hash vs JSON String
Option A: JSON String
SET session:{token} '{"userId":1001,"email":"j@example.com","role":"engineer","permissions":["read","write"]}' EX 3600
Simple. One key, one value. But updating a single field requires:
GET session:{token}→ deserialize JSON- Modify the field in application memory
SET session:{token} {updated JSON}→ re-serialize and overwrite
Under concurrent requests (two requests updating different session fields simultaneously), one overwrites the other's changes. Race condition.
Option B: Redis Hash
HSET session:{token} userId 1001 email "j@example.com" role "engineer"
HSET session:{token} lastSeen "1717000000"
EXPIRE session:{token} 3600
Individual field updates are atomic:
HSET session:{token} lastSeen "1717003600" → atomic, no read required
HINCRBY session:{token} pageViews 1 → atomic counter
Multiple fields can be updated without touching others. No race condition for independent field updates.
The trade-off: Hashes require one more key + the field name overhead. For sessions with 5–10 fields, the overhead is minimal. For sessions storing complex nested objects, you may need to serialize sub-objects as JSON strings within Hash fields.
Recommendation: Use a Hash for session storage. It enables atomic per-field updates and makes session data self-describing (HGETALL returns field names, not just a JSON blob).
// Session creation
async function createSession(userId: string, metadata: Record<string, string>) {
const token = generateSecureToken(); // crypto.randomBytes(32).toString('hex')
const key = `session:${token}`;
await redis.hset(key,
'userId', userId,
'createdAt', String(Date.now()),
'lastSeen', String(Date.now()),
...Object.entries(metadata).flat()
);
await redis.expire(key, 3600); // 1-hour idle timeout
return token;
}
// Session read (on every request)
async function getSession(token: string) {
const data = await redis.hgetall(`session:${token}`);
if (!data || Object.keys(data).length === 0) return null;
return data;
}
Session Expiry: Idle Timeout vs Absolute Timeout
Idle Timeout (Sliding Expiry)
The session expires if unused for N minutes. Activity resets the timer.
async function touchSession(token: string, idleTimeoutSeconds = 3600): Promise<boolean> {
const key = `session:${token}`;
// Update lastSeen and reset TTL atomically
const pipeline = redis.pipeline();
pipeline.hset(key, 'lastSeen', String(Date.now()));
pipeline.expire(key, idleTimeoutSeconds);
const results = await pipeline.exec();
// If the key didn't exist (expired between our check and touch), result is 0
const expireResult = results?.[1]?.[1] as number;
return expireResult === 1;
}
Call touchSession on every authenticated request. If it returns false, the session expired and the user should be logged out.
Absolute Timeout
The session expires at a fixed time after creation, regardless of activity. Implement by storing the absolute expiry timestamp in the session Hash and checking it on every access:
async function getSession(token: string) {
const data = await redis.hgetall(`session:${token}`);
if (!data || !data.userId) return null;
const absoluteExpiry = parseInt(data.absoluteExpiry ?? '0', 10);
if (absoluteExpiry && Date.now() > absoluteExpiry) {
await redis.del(`session:${token}`);
return null;
}
return data;
}
// On session creation: absolute expiry 24 hours from now
await redis.hset(key, 'absoluteExpiry', String(Date.now() + 86400 * 1000));
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion