Build a Distributed Session Store for 50M Active Sessions
caching distributed-systems reliability
System Design Deep Dive
Distributed Session Store
50 million logged-in users, one dead node, zero logouts.
A coat check at a busy theater works on a simple contract: you hand over your coat, you get a numbered ticket, and for the rest of the night that ticket is proof of who you are to the cleanup desk. The attendant does not re-verify your identity on every visit to the counter between acts. They check the ticket number against a rack position and hand the coat back in milliseconds. The system only breaks down if the rack burns down, the ticket gets lost, or two people are handed the same number.
A session store is the coat check for the web. Every authenticated request a user makes - load a page, call an API, open a websocket - needs to answer “who is this and are they still logged in” without re-running a full authentication flow each time. At 50 million active sessions, that lookup has to happen in under 5 milliseconds, millions of times per second, and it has to survive losing a server without a single user noticing they were logged out.
The naive approach is to store sessions in the same relational database that holds your user accounts. That works at 10,000 sessions. At 50 million it does not, for three compounding reasons. First, session reads vastly outnumber writes (every page view is a read, only login and logout are writes), so you are paying disk-backed query latency for what should be a microsecond-scale lookup. Second, sessions expire constantly and continuously - every active user’s session needs its TTL refreshed on each request if you support sliding expiry - which turns your database into an update-heavy workload it was never built for. Third, a relational database’s failover takes seconds, and during that window every authenticated request in your system fails.
The forces in tension are speed, durability, and availability during failure. We want session reads fast enough to be invisible (sub-5ms), we want a node crash to be invisible to the user (no forced logout), and we want a security model that does not leave 50 million long-lived tokens sitting around as a standing attack surface. We need to solve for in-memory speed, replicated durability, and graceful failover simultaneously, and each of those pulls the design in a different direction.
Requirements and Constraints
Functional Requirements
- Create a session on successful login and return an opaque session token to the client
- Validate a session token and return the associated session data (user ID, roles, metadata) on every authenticated request
- Support sliding expiry: each successful read extends the session’s time-to-live
- Support explicit logout (single session) and global logout (all sessions for a user)
- Rotate session tokens periodically without forcing re-authentication
- Survive the failure of any single storage node without invalidating active sessions
Non-Functional Requirements
- Read latency: p99 under 5ms for a session lookup
- Write latency: p99 under 10ms for session create, update, and TTL refresh
- Scale: 50 million concurrent active sessions, sustained read throughput of 800,000 ops/sec, peak 1.5M ops/sec
- Availability: 99.99% for the session validation path - this sits in front of every authenticated API call
- Durability: a node failure must not log out an active user; acceptable to lose sessions that were created in the last 100ms before a crash, not acceptable to lose anything older
- Session size: average 1.2KB per session (user ID, roles, device metadata, feature flags)
Constraints and Assumptions
- We assume a stateless application tier - app servers do not pin users to specific instances based on session location
- We are not designing the authentication flow itself (password checks, OAuth handshakes) - only what happens after a user is authenticated
- We assume client devices can store an opaque token (cookie or Authorization header) but never see the session’s internal data structure
- Long-term session analytics (login history, audit trails) are out of scope - those live in a separate event log, not the hot session store
- We assume a single logical datacenter region per deployment; cross-region session replication is discussed but not the primary design target
High-Level Architecture
The system has five major components: the Session Gateway, Redis Cluster (the session store itself), the Replication Layer, the Token Service, and an Async Write-Behind Log for durability beyond memory.
A request arrives at the Session Gateway carrying a session token in a cookie or header. The Gateway computes which shard owns that token using consistent hashing, and issues a GET against the owning Redis Cluster node. If the session exists and has not expired, the Gateway extends its TTL in the same round trip (sliding expiry) and forwards the request to the application tier with the deserialized session payload attached. If the node holding the shard is unreachable, the Gateway transparently retries against that shard’s replica, which Redis Cluster promotes to primary within seconds of detecting the failure.
The Redis Cluster is the source of truth for live session reads and writes. It is sharded across 16,384 hash slots, each slot owned by one primary node with at least one replica. The Replication Layer streams every write from a primary to its replicas asynchronously, and synchronously enough (via WAIT) for sessions that require strict durability, like the session created at login. The Token Service issues, validates the format of, and rotates session tokens - it does not own session data, only the cryptographic and routing logic baked into the token itself. The Async Write-Behind Log ships a sampled, compacted copy of session lifecycle events (create, rotate, revoke) to a durable log for audit and for warming a cold cluster after a full regional failure.
Data flows in a tight loop: client sends token, Gateway resolves shard, Redis answers in microseconds, Gateway refreshes TTL, response returns. The only component on this hot path that does meaningful computation is the Gateway’s shard resolution, which is a single hash operation.
The entire system is designed so that session validation - the operation called on every single authenticated request - touches exactly one network hop to one Redis node. Everything else (token rotation, replication, write-behind logging) happens off the hot path, either asynchronously or only at session creation. A 5ms budget does not survive a second network hop.
Component Deep Dives
The Session Gateway
The Session Gateway’s job is to turn an opaque token into a shard lookup and a sub-5ms answer, without becoming a stateful bottleneck itself.
A smart engineer’s first instinct is to put a local cache in front of Redis inside the Gateway to shave off the network hop entirely. That works until you have more than one Gateway instance, which you always do at this scale - now you have N caches that can each be stale by a different amount, and a logout on one Gateway instance does not invalidate the cached copy on the other nineteen. We do use a very short-lived (under 1 second) local cache for read-heavy hot sessions, but we treat Redis as the only durable source of truth and never let a Gateway serve a write without going through it.
The Gateway is stateless and horizontally scaled behind a load balancer. Each instance independently computes the target shard for a token using the same hash function Redis Cluster uses internally (CRC16 of the token’s hash tag, modulo 16,384), so it can route directly to the owning node instead of bouncing through a Redis Cluster redirect on every request.
# Session Gateway: resolve shard and fetch session with sliding TTL refresh
# Demonstrates: CRC16 slot resolution, single round-trip read + TTL extend
import zlib
TOTAL_SLOTS = 16384
SESSION_TTL_SECONDS = 1800 # 30 minutes sliding window
def hash_slot(token: str) -> int:
# Redis Cluster CRC16 slot algorithm, honoring {hashtag} if present
if "{" in token and "}" in token:
start = token.index("{") + 1
end = token.index("}", start)
if end > start:
token = token[start:end]
return zlib.crc32(token.encode()) % TOTAL_SLOTS
class SessionGateway:
def __init__(self, cluster_client, shard_map):
self.cluster = cluster_client
self.shard_map = shard_map # slot range -> node endpoint
def get_session(self, token: str) -> dict | None:
slot = hash_slot(token)
node = self.shard_map.node_for_slot(slot)
# Lua script: GET + sliding TTL refresh in one atomic round trip
result = self.cluster.eval(
REFRESH_AND_GET_SCRIPT,
keys=[f"session:{token}"],
args=[SESSION_TTL_SECONDS],
node=node,
)
if result is None:
return None
return self.deserialize(result)
REFRESH_AND_GET_SCRIPT = """
local val = redis.call('GET', KEYS[1])
if val then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return val
"""Premium Content
Unlock the full article along with everything else in the archive — all in one place.