Module P-9·16 min read

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:

  1. GET session:{token} → deserialize JSON
  2. Modify the field in application memory
  3. 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).

typescript
// 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.

typescript
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:

typescript
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; }
typescript
// On session creation: absolute expiry 24 hours from now await redis.hset(key, 'absoluteExpiry', String(Date.now() + 86400 * 1000));

Combining Both

Most security-conscious systems use both:

  • Idle timeout: 30 minutes of inactivity
  • Absolute timeout: 24 hours regardless of activity
typescript
async function createSession(userId: string) { const token = generateSecureToken(); const key = `session:${token}`; const now = Date.now(); await redis.hset(key, 'userId', userId, 'createdAt', String(now), 'lastSeen', String(now), 'absoluteExpiry', String(now + 86400 * 1000) ); await redis.expire(key, 1800); // 30-minute idle timeout return token; }

Multi-Device Session Management

Users log in from multiple devices. Each device gets its own session token. You need to:

  • List all active sessions for a user
  • Revoke a specific session (logout from one device)
  • Revoke all sessions (logout from all devices, e.g., after password change)

Track Sessions per User with a Set

typescript
async function createSession(userId: string, deviceInfo: string) { const token = generateSecureToken(); const sessionKey = `session:${token}`; const userSessionsKey = `user:sessions:${userId}`; await redis.hset(sessionKey, 'userId', userId, 'device', deviceInfo, 'createdAt', String(Date.now()) ); await redis.expire(sessionKey, 3600); // Track this session under the user's session index await redis.sadd(userSessionsKey, token); await redis.expire(userSessionsKey, 3600); // clean up if user is inactive return token; } async function getActiveSessions(userId: string) { const tokens = await redis.smembers(`user:sessions:${userId}`); // Filter out expired sessions (their Hash keys won't exist) const sessions = await Promise.all( tokens.map(async (token) => { const data = await redis.hgetall(`session:${token}`); return Object.keys(data).length > 0 ? { token, ...data } : null; }) ); return sessions.filter(Boolean); } async function revokeSession(userId: string, token: string) { await redis.del(`session:${token}`); await redis.srem(`user:sessions:${userId}`, token); } async function revokeAllSessions(userId: string) { const tokens = await redis.smembers(`user:sessions:${userId}`); if (tokens.length > 0) { await redis.del(...tokens.map(t => `session:${t}`)); } await redis.del(`user:sessions:${userId}`); }

Memory cleanup: The user sessions Set may contain tokens of expired sessions (the session Hash expired but the token remains in the Set). The getActiveSessions function handles this by checking if the Hash exists, but the Set grows over time. Add a periodic cleanup or use SSCAN + EXISTS to prune stale tokens.


The Read-from-Replica Consistency Trap

Many Redis setups route read commands to replicas to reduce primary load. Session reads are a common target.

The problem: Replication in Redis is asynchronous. When a session is created or updated on the primary, the change propagates to replicas with a small delay (typically < 1ms, but up to seconds under load or network issues).

If a request is authenticated (session created on primary), and the next request reads the session from a replica before replication completes, the session lookup returns nil — the user appears logged out.

Request 1: POST /login  → session created on PRIMARY (token: abc123)
Response:  Set-Cookie: session=abc123

Request 2: GET /profile → reads from REPLICA (replica lag: 50ms)
           HGETALL session:abc123 → {} (not replicated yet)
           → user appears unauthenticated → 401 Unauthorized

Fix: Always read sessions from the primary.

typescript
// ioredis: use primary for session operations const primaryRedis = new Redis({ host: 'redis-primary', port: 6379 }); const replicaRedis = new Redis({ host: 'redis-replica', port: 6379 }); // Sessions: always primary async function getSession(token: string) { return primaryRedis.hgetall(`session:${token}`); } // Non-critical reads: can use replica async function getProductData(productId: string) { return replicaRedis.get(`product:${productId}`); }

For most session workloads, the primary handles the load comfortably — sessions are a small fraction of total Redis traffic.


Session Token Security

This is not Redis-specific, but engineers implementing Redis sessions often get this wrong:

Use a cryptographically secure random token:

typescript
import { randomBytes } from 'crypto'; function generateSecureToken(): string { return randomBytes(32).toString('hex'); // 64 hex characters, 256 bits of entropy }

Do not use UUIDs (v4 UUIDs are random but some implementations have flaws; 122 bits of entropy vs 256).
Do not use incrementing integers or predictable patterns.
Do not sign session tokens with HMAC and put user data in the token unless you understand session forgery risks.

Set the cookie correctly:

Set-Cookie: session={token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600
  • HttpOnly — JavaScript cannot access the cookie (XSS protection)
  • Secure — only sent over HTTPS
  • SameSite=Strict — CSRF protection

Summary

  • Use a Hash for session data — atomic per-field updates, self-documenting, no race conditions
  • Idle timeout via EXPIRE on each request; absolute timeout via a stored expiry field
  • Multi-device sessions: track token Set per user (user:sessions:{userId}); revoke by DEL session Hash + SREM from Set
  • Always read sessions from the primary — replica lag causes silent authentication failures
  • Secure tokens: 256-bit cryptographically random, stored in HttpOnly; Secure; SameSite=Strict cookies

Next: P-10 — Connection Pooling and Client Configuration — TCP connection overhead, ioredis pool sizing, reconnection strategies, and health check patterns for production clients.

© 2026 Jatin Jain Saraf (JJS). All rights reserved.