Module P-9·11 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.

Written by Jatin Jain Saraf · Architect of SupraScan

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).

// 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 & Register

Discussion

0

Join the discussion

Loading comments...

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