Build a Concurrent Device Session Manager


distributed-systems scalability reliability

System Design Deep Dive

Concurrent Device Session Manager

Enforce N-device limits across distributed servers without a race that lets the (N+1)th device slip through

⏱ 14 min read📐 Advanced🏗️ Distributed Systems

Think about a household sharing a streaming subscription. The plan says two screens at once - and most of the time, that rule means nothing, because nobody checks. But the moment a third person tries to press play while two streams are already running, the platform has to make a hard call: reject the request or silently kick one of the existing sessions. That decision, which sounds trivial when described in a sentence, conceals one of the trickiest distributed systems problems you will encounter in a product that has to work at global scale.

The difficulty comes from the distributed nature of streaming infrastructure. Any given user’s devices may connect to different regional API servers. There is no single node that has a complete, up-to-date picture of how many devices are actively streaming for a given account at this exact moment. Building a globally correct count under that constraint means accepting a coordination cost. Make that cost too high and every play request adds 50+ ms of latency. Make it too loose and you allow the N+1 device to slip through on a race window between two servers that both read a count of N-1 and both increment to N.

Session management becomes harder still when you account for reconnects. A device that loses connectivity briefly should be able to resume its session without being evicted and forced through the full authentication cycle again. But that same reconnect path is also the attack surface where a shared credential gets passed around a friend group. A robust design has to distinguish between a legitimate resume and a new device masquerading as an existing one, all within the same code path.

There are three core architectural challenges this post addresses. First: how do you count live sessions with atomic precision across N stateless API servers? Second: how do you define “live” in a system where clients can go dark silently - no TCP FIN, no HTTP teardown? Third: how do you enforce per-plan limits - where a Basic subscriber gets 1 screen and Premium gets 4 - without making the plan lookup a synchronous bottleneck on every play request?

Requirements and Constraints

Functional requirements:

  • Enforce concurrent device limits per subscription plan: Basic = 1 device, Standard = 2 devices, Premium = 4 devices
  • When a new device connects and the limit is already at capacity, evict the oldest active session (lowest heartbeat timestamp) and allow the new one
  • Track device identity via fingerprints (device ID + user agent + IP subnet) to distinguish unique devices
  • Handle reconnects idempotently - a device that lost connectivity and reconnects within the session TTL should refresh its existing session, not create a new one
  • Provide a session list endpoint so users can see and manually revoke sessions from any device
  • Support dynamic plan changes without requiring all sessions to reconnect

Non-functional requirements:

  • Sub-100ms enforcement latency at p99 - the check-and-admit decision must be fast enough to not degrade playback start time
  • 99.99% correctness - no N+1 slip-throughs under concurrent connection storms
  • Support 1 million concurrent active sessions across the platform
  • No single global coordinator - the design must survive the loss of any individual node without service degradation

Constraints:

  • Geo-distributed deployment across at least 3 regions
  • Sessions managed in Redis (not in the API server’s memory) to survive server restarts and horizontal scaling
  • Plan limits are stored in Postgres but must be cached to avoid database hits on every play request
  • The heartbeat interval is 30 seconds; a session with no heartbeat for 90 seconds is considered dead

High-Level Architecture

The system is composed of five major layers that coordinate to enforce session limits reliably.

Concurrent Device Session Manager Architecture

The API Gateway sits at the edge and routes all client traffic to the Session Service cluster. It performs request authentication via JWT validation and injects the verified user ID into downstream service calls. Critically, the gateway itself does not make session decisions - it only authenticates identity and forwards.

The Session Service is a horizontally scalable, stateless cluster. Every node is identical. When a device sends a play request, the Session Service performs the check-and-admit sequence: look up the user’s plan limit from the Plan Store cache, then execute an atomic Lua script against the Redis Session Registry that counts current sessions, evicts the oldest if needed, and registers the new session - all in a single round-trip. Because the logic runs inside Redis, it does not matter which Session Service node handles the request; the result is always consistent.

The Redis Session Registry is the system’s source of truth for session state. It uses a sorted set per user, keyed by session ID with the score set to the last heartbeat timestamp. The sorted set structure lets us retrieve the session count, find the oldest session (lowest score), and remove expired sessions all in O(log N) time. The Heartbeat Worker is a lightweight background process that runs on each client device and sends a keep-alive ping every 30 seconds. The Plan Store is Postgres for durability with a Redis cache layer that holds plan limits with a 60-second TTL.

Key Design Decision

The atomic Lua script in Redis is the entire correctness story. By pushing the count-check-evict-insert sequence into a single script that Redis executes serially (Redis is single-threaded for command execution), you eliminate the TOCTOU race that would otherwise let two simultaneous connections both read count=N-1 and both succeed when only one should.

The Session Registry

Every user’s active sessions live in a Redis sorted set at key sessions:{userId}. The score is the Unix timestamp (in milliseconds) of the most recent heartbeat received for that session. This means the member with the lowest score is always the stalest session - the one we evict when the limit is exceeded.

Session Registry Internals - Redis Sorted Set Structure

The core Redis operations for session management are:

# Add or refresh a session (ZADD with NX flag for new sessions)
ZADD sessions:{userId} {timestamp_ms} {sessionId}

# Update heartbeat for existing session (XX flag only updates, never adds)
ZADD sessions:{userId} XX {timestamp_ms} {sessionId}

# Count sessions with a heartbeat newer than the expiry threshold
ZCOUNT sessions:{userId} {min_valid_timestamp} +inf

# Get the oldest session (lowest score = oldest heartbeat)
ZRANGE sessions:{userId} 0 0 WITHSCORES

# Remove all sessions that have not heartbeated within TTL
ZREMRANGEBYSCORE sessions:{userId} -inf {expiry_threshold}

# Remove a specific session on explicit logout
ZREM sessions:{userId} {sessionId}

These individual commands would be fine for reads, but the check-and-admit path needs to be atomic. The sequence is: count valid sessions, compare against the plan limit, conditionally evict the oldest, then insert the new session. Any gap between those steps is a race window.

The solution is a Lua script executed by Redis atomically:

-- KEYS[1] = sessions:{userId}
-- KEYS[2] = session_meta:{sessionId}  (hash for metadata)
-- ARGV[1] = sessionId (new session being registered)
-- ARGV[2] = current_timestamp_ms
-- ARGV[3] = plan_limit (integer, e.g. 2)
-- ARGV[4] = expiry_threshold_ms (timestamp below which sessions are dead)
-- ARGV[5] = device_fingerprint
-- ARGV[6] = device_type
-- ARGV[7] = ip_address

local key = KEYS[1]
local meta_key = KEYS[2]
local session_id = ARGV[1]
local now = tonumber(ARGV[2])
local plan_limit = tonumber(ARGV[3])
local expiry = tonumber(ARGV[4])

-- Step 1: Prune dead sessions (no heartbeat within TTL)
redis.call('ZREMRANGEBYSCORE', key, '-inf', expiry)

-- Step 2: Check if this sessionId already exists (reconnect case)
local existing_score = redis.call('ZSCORE', key, session_id)
if existing_score then
  -- Reconnect: just refresh the heartbeat timestamp
  redis.call('ZADD', key, now, session_id)
  redis.call('HSET', meta_key, 'last_seen', now)
  return {1, 'REFRESHED', session_id}
end

-- Step 3: Count currently active sessions
local active_count = redis.call('ZCARD', key)

-- Step 4: If under limit, add immediately
if active_count < plan_limit then
  redis.call('ZADD', key, now, session_id)
  redis.call('HSET', meta_key,
    'device_type', ARGV[6],
    'ip', ARGV[7],
    'fingerprint', ARGV[5],
    'created_at', now,
    'last_seen', now
  )
  redis.call('EXPIRE', meta_key, 300)
  return {1, 'ADMITTED', session_id}
end

-- Step 5: At limit - evict the oldest session
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local evicted_id = oldest[1]

redis.call('ZREM', key, evicted_id)
redis.call('DEL', 'session_meta:' .. evicted_id)
redis.call('SADD', 'evicted_sessions', evicted_id)
redis.call('EXPIRE', 'evicted_sessions', 60)

-- Step 6: Add the new session
redis.call('ZADD', key, now, session_id)
redis.call('HSET', meta_key,
  'device_type', ARGV[6],
  'ip', ARGV[7],
  'fingerprint', ARGV[5],
  'created_at', now,
  'last_seen', now
)
redis.call('EXPIRE', meta_key, 300)

return {1, 'ADMITTED_WITH_EVICTION', evicted_id}

Race Condition Without Lua

If you implement the count-check-evict-insert as separate Redis commands from application code, two requests arriving simultaneously for the same user (on different Session Service nodes) will both read count=1 against a limit of 2, both conclude they can proceed, and both insert - leaving you at 3 active sessions. At high concurrency, this race is not theoretical; it happens on every traffic spike. Lua scripts execute atomically within Redis’s single-threaded command loop, eliminating this entirely.

The Heartbeat Mechanism

A session is considered alive only as long as the client continues sending heartbeats. If a device goes dark - user closes the app, laptop lid shuts, connectivity drops - there is no teardown signal. The heartbeat timeout is the only mechanism that reclaims the session slot.

Session Data Flow and Heartbeat Lifecycle

The heartbeat interval is 30 seconds. The TTL (the time after which a session is considered expired and eligible for pruning) is 90 seconds - three missed intervals. This provides resilience against transient network hiccups while still reclaiming slots within a predictable window.

Here is the client-side heartbeat implementation in Python:

import asyncio
import httpx
import time

class SessionHeartbeat:
    def __init__(self, session_id: str, auth_token: str, base_url: str):
        self.session_id = session_id
        self.auth_token = auth_token
        self.base_url = base_url
        self.interval = 30  # seconds
        self._running = False
        self._task: asyncio.Task | None = None

    async def start(self):
        self._running = True
        self._task = asyncio.create_task(self._heartbeat_loop())

    async def stop(self):
        self._running = False
        if self._task:
            self._task.cancel()
        # Explicit session termination on clean exit
        await self._send_terminate()

    async def _heartbeat_loop(self):
        async with httpx.AsyncClient() as client:
            while self._running:
                try:
                    resp = await client.post(
                        f"{self.base_url}/sessions/heartbeat",
                        json={"session_id": self.session_id},
                        headers={"Authorization": f"Bearer {self.auth_token}"},
                        timeout=5.0,
                    )
                    if resp.status_code == 410:
                        # Session was evicted server-side
                        await self._handle_eviction()
                        return
                except httpx.RequestError:
                    # Network error - session will expire naturally via TTL
                    pass
                await asyncio.sleep(self.interval)

    async def _handle_eviction(self):
        # Signal the player to show "session limit reached" UI
        print(f"Session {self.session_id} was evicted. Another device took your slot.")
        self._running = False

    async def _send_terminate(self):
        async with httpx.AsyncClient() as client:
            try:
                await client.delete(
                    f"{self.base_url}/sessions/{self.session_id}",
                    headers={"Authorization": f"Bearer {self.auth_token}"},
                    timeout=3.0,
                )
            except httpx.RequestError:
                pass  # TTL will clean it up

And the server-side heartbeat handler in Go:

func (s *SessionService) HandleHeartbeat(w http.ResponseWriter, r *http.Request) {
    var req struct {
        SessionID string `json:"session_id"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request", http.StatusBadRequest)
        return
    }

    userID := r.Context().Value("user_id").(string)
    key := fmt.Sprintf("sessions:%s", userID)
    now := time.Now().UnixMilli()

    // ZADD with XX flag: only update if member exists
    updated, err := s.redis.ZAddXX(r.Context(), key, &redis.Z{
        Score:  float64(now),
        Member: req.SessionID,
    }).Result()

    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    if updated == 0 {
        // Session not found - it was evicted or expired
        w.WriteHeader(http.StatusGone) // 410
        return
    }

    // Refresh metadata TTL
    metaKey := fmt.Sprintf("session_meta:%s", req.SessionID)
    s.redis.Expire(r.Context(), metaKey, 5*time.Minute)

    w.WriteHeader(http.StatusNoContent)
}

Heartbeat as Source of Truth

The heartbeat timestamp in the sorted set score is not advisory metadata - it is the canonical definition of session liveness. There is no separate “is_active” boolean, no session state machine in Postgres. A session exists if and only if its key is present in the sorted set with a score newer than (now - TTL). This simplicity means there is no state to get out of sync and no edge case where a session is “active” in one store but “dead” in another.

Plan-Aware Limits

The plan limit (1, 2, or 4 devices) must be retrieved on every play request, but it cannot come from a synchronous Postgres query without destroying latency. The solution is a two-tier cache: plan data lives in Postgres as the source of truth, is loaded into Redis with a 60-second TTL on first access, and is also held in each Session Service node’s in-process cache with a 30-second TTL.

class PlanStore:
    def __init__(self, db: asyncpg.Connection, redis: aioredis.Redis):
        self.db = db
        self.redis = redis
        self._local_cache: dict[str, tuple[int, float]] = {}  # userId -> (limit, expires_at)

    async def get_device_limit(self, user_id: str) -> int:
        # Tier 1: in-process cache (30s TTL)
        cached = self._local_cache.get(user_id)
        if cached and cached[1] > time.time():
            return cached[0]

        # Tier 2: Redis cache (60s TTL)
        redis_key = f"plan_limit:{user_id}"
        limit_str = await self.redis.get(redis_key)
        if limit_str:
            limit = int(limit_str)
            self._local_cache[user_id] = (limit, time.time() + 30)
            return limit

        # Tier 3: Postgres (source of truth)
        row = await self.db.fetchrow(
            """
            SELECT p.concurrent_device_limit
            FROM subscriptions s
            JOIN plans p ON p.id = s.plan_id
            WHERE s.user_id = $1 AND s.status = 'active'
            """,
            user_id
        )
        limit = row["concurrent_device_limit"] if row else 1  # Default to most restrictive

        await self.redis.setex(redis_key, 60, str(limit))
        self._local_cache[user_id] = (limit, time.time() + 30)
        return limit

    async def invalidate(self, user_id: str):
        """Called when a user upgrades or downgrades their plan."""
        self._local_cache.pop(user_id, None)
        await self.redis.delete(f"plan_limit:{user_id}")

When a user upgrades from Standard to Premium mid-session, the plan change triggers an invalidation call to all Session Service nodes (via a Redis pub/sub channel), ensuring the new limit takes effect within at most 30 seconds without requiring any sessions to reconnect.

Real World

Netflix enforces concurrent stream limits per plan and has publicly described their approach as Redis-backed session state with heartbeat-driven TTLs. Amazon Prime Video uses a similar model, where the eviction message (“someone else started playing on another device”) is delivered as a push notification to the evicted device’s app via their notification infrastructure. The 90-second TTL window is consistent with what you observe empirically - try pulling your network cable during a stream and you have about 60-90 seconds before another device can take your slot.

Data Model

Postgres Tables

CREATE TABLE plans (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name         TEXT NOT NULL UNIQUE,             -- 'basic', 'standard', 'premium'
  display_name TEXT NOT NULL,
  concurrent_device_limit INT NOT NULL DEFAULT 1,
  price_cents  INT NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

INSERT INTO plans (name, display_name, concurrent_device_limit, price_cents) VALUES
  ('basic',    'Basic',    1, 499),
  ('standard', 'Standard', 2, 999),
  ('premium',  'Premium',  4, 1499);

CREATE TABLE subscriptions (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id    UUID NOT NULL REFERENCES users(id),
  plan_id    UUID NOT NULL REFERENCES plans(id),
  status     TEXT NOT NULL DEFAULT 'active',   -- 'active', 'cancelled', 'expired'
  starts_at  TIMESTAMPTZ NOT NULL,
  ends_at    TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CONSTRAINT unique_active_user_sub UNIQUE (user_id, status) DEFERRABLE INITIALLY DEFERRED
);

CREATE TABLE sessions (
  id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id          UUID NOT NULL REFERENCES users(id),
  session_token    TEXT NOT NULL UNIQUE,
  device_id        TEXT NOT NULL,
  device_type      TEXT NOT NULL,               -- 'mobile', 'tv', 'browser', 'tablet'
  device_name      TEXT,
  ip_address       INET,
  fingerprint      TEXT NOT NULL,               -- hash of device_id + user_agent + ip_subnet
  created_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  last_heartbeat   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  terminated_at    TIMESTAMPTZ,
  termination_reason TEXT                        -- 'evicted', 'logout', 'expired', 'plan_change'
);

CREATE INDEX idx_sessions_user_active ON sessions (user_id) WHERE terminated_at IS NULL;
CREATE INDEX idx_sessions_fingerprint ON sessions (fingerprint);

Redis Key Structure

sessions:{userId}           → Sorted Set
                              member = sessionId
                              score  = last_heartbeat_timestamp_ms

session_meta:{sessionId}    → Hash
                              device_type, ip, fingerprint,
                              created_at, last_seen

plan_limit:{userId}         → String (TTL 60s)
                              integer concurrent_device_limit

evicted_sessions            → Set (TTL 60s)
                              recently evicted session IDs
                              (polled by heartbeat handler to return 410)

Sessions are partitioned by userId naturally - all of a user’s sessions live in the same Redis sorted set. For Redis Cluster deployments, hash tags ensure a user’s sessions always land on the same shard: {userId}.sessions rather than sessions:{userId} when using hash-tag-aware routing.

Key Algorithms and Protocols

Full Atomic Eviction Script

The complete Lua script with all edge cases handled, ready for production use:

-- register_session.lua
-- Atomically checks limit, evicts oldest if needed, and registers new session.
--
-- KEYS[1] = sessions:{userId}     (sorted set)
-- KEYS[2] = session_meta:{newId}  (hash for new session metadata)
-- ARGV[1] = newSessionId
-- ARGV[2] = currentTimestampMs
-- ARGV[3] = planLimit
-- ARGV[4] = expiryThresholdMs     (now - TTL_ms, sessions older than this are dead)
-- ARGV[5] = deviceFingerprint
-- ARGV[6] = deviceType
-- ARGV[7] = ipAddress
--
-- Returns: {status_code, result_code, evicted_session_id_or_nil}
--   status_code: 1=success, 0=error
--   result_code: 'REFRESHED' | 'ADMITTED' | 'ADMITTED_WITH_EVICTION'

local sessions_key  = KEYS[1]
local meta_key      = KEYS[2]
local new_id        = ARGV[1]
local now           = tonumber(ARGV[2])
local plan_limit    = tonumber(ARGV[3])
local expiry        = tonumber(ARGV[4])
local fingerprint   = ARGV[5]
local device_type   = ARGV[6]
local ip_addr       = ARGV[7]
local TTL_META_SEC  = 300

-- Prune stale sessions first
redis.call('ZREMRANGEBYSCORE', sessions_key, '-inf', expiry)

-- Check reconnect: session already present (device refreshing a live session)
local existing = redis.call('ZSCORE', sessions_key, new_id)
if existing then
  redis.call('ZADD', sessions_key, now, new_id)
  redis.call('HSET', meta_key, 'last_seen', tostring(now))
  return {1, 'REFRESHED', false}
end

-- Check fingerprint-based reconnect: same physical device, new session ID
local all_sessions = redis.call('ZRANGE', sessions_key, 0, -1)
for _, sid in ipairs(all_sessions) do
  local fp = redis.call('HGET', 'session_meta:' .. sid, 'fingerprint')
  if fp == fingerprint then
    -- Same device reconnecting with a new session ID - evict old, admit new
    redis.call('ZREM', sessions_key, sid)
    redis.call('DEL', 'session_meta:' .. sid)
    redis.call('ZADD', sessions_key, now, new_id)
    redis.call('HSET', meta_key,
      'device_type', device_type,
      'ip', ip_addr,
      'fingerprint', fingerprint,
      'created_at', tostring(now),
      'last_seen', tostring(now))
    redis.call('EXPIRE', meta_key, TTL_META_SEC)
    return {1, 'REFRESHED', sid}
  end
end

-- Brand new session from a distinct device
local active_count = redis.call('ZCARD', sessions_key)

if active_count < plan_limit then
  -- Under limit: admit directly
  redis.call('ZADD', sessions_key, now, new_id)
  redis.call('HSET', meta_key,
    'device_type', device_type,
    'ip', ip_addr,
    'fingerprint', fingerprint,
    'created_at', tostring(now),
    'last_seen', tostring(now))
  redis.call('EXPIRE', meta_key, TTL_META_SEC)
  return {1, 'ADMITTED', false}
end

-- At limit: evict the session with the oldest heartbeat (lowest score)
local oldest_pair = redis.call('ZRANGE', sessions_key, 0, 0, 'WITHSCORES')
local evicted_id  = oldest_pair[1]

redis.call('ZREM', sessions_key, evicted_id)
redis.call('DEL', 'session_meta:' .. evicted_id)

-- Track evicted session for 410 response on next heartbeat
redis.call('SADD', 'evicted_sessions', evicted_id)
redis.call('EXPIRE', 'evicted_sessions', 60)

-- Admit the new session
redis.call('ZADD', sessions_key, now, new_id)
redis.call('HSET', meta_key,
  'device_type', device_type,
  'ip', ip_addr,
  'fingerprint', fingerprint,
  'created_at', tostring(now),
  'last_seen', tostring(now))
redis.call('EXPIRE', meta_key, TTL_META_SEC)

return {1, 'ADMITTED_WITH_EVICTION', evicted_id}

Reconnect Idempotency

The reconnect path is critical for user experience. A mobile device that moves between WiFi and cellular will briefly lose connectivity and then reconnect. The session service must handle this without forcing the user to log in again or counting it as a new device.

The idempotency strategy is layered:

  1. Session ID reuse: Clients persist their session ID locally. On reconnect, they send the same session ID. The Lua script checks for it first via ZSCORE and refreshes rather than creating a new entry.
  2. Fingerprint matching: If the session ID is missing (app was cleared, token expired), the script scans all active sessions for a matching device fingerprint. A match is treated as a reconnect, not a new device.
  3. Grace window: The 90-second TTL means a device can be offline for up to 90 seconds without losing its slot. A reconnect within that window is always free.

Session Fingerprinting

import hashlib

def compute_device_fingerprint(
    device_id: str,
    user_agent: str,
    ip_address: str,
) -> str:
    """
    Produces a stable fingerprint for a device.
    Uses /24 subnet of IP (not full IP) to handle DHCP lease changes.
    """
    ip_subnet = ".".join(ip_address.split(".")[:3])  # /24 subnet
    raw = f"{device_id}|{user_agent}|{ip_subnet}"
    return hashlib.sha256(raw.encode()).hexdigest()[:16]

Using the /24 subnet instead of the full IP prevents false “new device” detection when a device’s DHCP lease changes, which is common on mobile networks.

Scaling and Performance

Redis Cluster Sharding and Session Service Scaling

Capacity Estimation

  • 1 million concurrent sessions, distributed across users
  • Each sorted set entry: ~50 bytes (session ID string + score)
  • Each session metadata hash: ~200 bytes
  • Total Redis memory: 1M x 250 bytes = 250 MB - trivially small for Redis
  • Heartbeat rate: 1M sessions / 30 seconds = ~33,000 writes/second to Redis
  • Play requests (new session admits): estimate 50,000/second at peak

At 33,000 writes/second, a single Redis node (capable of 100,000+ ops/second) handles the heartbeat load with headroom. The Lua script executes in ~0.5ms per call. At 50,000 new session admits/second, you need Redis sharding.

Redis Cluster Sharding

The natural partition key is userId. Hash tag routing ensures all keys for a user land on the same shard:

import hashlib

def get_redis_shard(user_id: str, num_shards: int = 3) -> int:
    """Consistent hash to determine which Redis shard handles a user."""
    digest = int(hashlib.md5(user_id.encode()).hexdigest(), 16)
    return digest % num_shards

# In practice with Redis Cluster, use hash tags:
# Key: {userId}.sessions  (the {} tells Redis Cluster to hash only the userId portion)

With 3 Redis shards and consistent hashing, capacity scales linearly. Adding a shard requires rehashing, but because sessions are short-lived (90-second TTL maximum), the transition window is brief and no data migration is needed - old sessions simply expire and new ones route to the new shard.

Session Service Scaling

The Session Service nodes are stateless and scale horizontally behind the API Gateway. The only shared state is in Redis. Session Service nodes hold a 30-second in-process cache of plan limits to avoid Redis reads on every heartbeat. Memory usage per node is minimal: a Go service handling 10,000 concurrent requests uses roughly 200 MB of heap.

Real World

Disney+ enforced concurrent stream limits at launch in 2019 and hit immediate scaling issues when 10 million subscribers tried to connect simultaneously. Their post-mortem noted that the session registry became a hotspot because they initially used a single Redis key per account without sharding by region. The fix involved regional session stores with eventual cross-region sync - accepting that a device in Tokyo might briefly share a slot with one in New York, but only for the 5-second sync interval. For most streaming platforms, this is an acceptable tradeoff.

Failure Modes and Recovery

FailureImpactMitigation
Redis shard downAll users on that shard cannot start new sessions; heartbeats failRedis Sentinel / Cluster automatic failover; read replicas for heartbeat reads; fail-open policy (allow new sessions) during outage
Session Service node crashIn-flight requests lost; sessions in flight are not admittedSessions use client-side retry with exponential backoff; sessions not in Redis are not counted
Clock skew between nodesHeartbeat timestamps inconsistent; sessions may appear older or newer than they areUse Redis server time via TIME command in Lua script instead of application-supplied timestamps
Network partition (client-side)Client cannot send heartbeats; session expires after 90sClient-side retry with backoff; session ID persisted locally for reconnect within TTL
Network partition (server-side)Session Service loses contact with RedisCircuit breaker; fail-open with local in-memory session tracking; reconcile with Redis on reconnect
Plan store unavailableCannot look up device limitUse in-process cached value; if cache expired, fail-safe to most restrictive limit (1 device) rather than crashing
Redis memory exhaustionZADD fails; sessions cannot be registeredAlert at 70% memory utilization; configure maxmemory-policy allkeys-lru as backstop; prune aggressively via reduced TTL

Clock Skew Warning

Never use application server time as the heartbeat timestamp passed to Redis. Two Session Service nodes with even a 1-second clock difference will produce inconsistent session ordering in the sorted set. The oldest-session eviction policy depends on correct relative ordering. Use Redis’s own TIME command inside the Lua script: local now = redis.call(‘TIME’); local ts = tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000) to get milliseconds from the Redis server’s clock.

Comparison of Approaches

ApproachCorrectnessLatencyComplexityFailure Mode
Redis Sorted Set + Lua (recommended)High - atomic operationsLow (0.5-2ms)MediumRedis availability
Postgres row locks (SELECT FOR UPDATE)High - ACID transactionsHigh (10-50ms)LowDB connection pool exhaustion
In-memory coordinator (single node)High - serial executionLowLowSingle point of failure
Gossip protocol (eventual consistency)Low - race windowsVery lowHighAllows N+1 slip-through
ZooKeeper/etcd distributed locksHigh - consensusMedium (5-15ms)HighLock contention at scale

The Redis Sorted Set with Lua approach is the right choice because it hits the sweet spot: correctness comes from Redis’s single-threaded execution model (the Lua script is atomic by definition), latency is excellent because Redis is in-memory, and the failure mode is Redis availability - which is well-understood and mitigated by Sentinel/Cluster. Postgres row locks would work but cannot sustain 50,000 new session admits/second without severe lock contention. The gossip approach is a common mistake - distributed gossip converges too slowly to prevent N+1 admission under burst traffic.

Key Takeaways

  • The atomic Lua script is everything. The entire correctness guarantee rests on executing count-check-evict-insert as a single atomic operation inside Redis. Never split this into multiple application-layer Redis calls.
  • Heartbeat TTL defines liveness. There is no separate session state machine. A session is alive if and only if its sorted set entry has a score newer than (now - TTL). Simplicity here prevents state inconsistency.
  • Fingerprint-based reconnect prevents double-counting. When a device reconnects with a new session ID (app cleared, token rotated), fingerprint matching detects it as the same device and replaces rather than adds a new slot.
  • Clock skew corrupts eviction ordering. Use Redis server time inside Lua scripts, not application server timestamps. Even a 1-second difference between Session Service nodes can cause the wrong session to be evicted.
  • Plan limits must be cached close to the enforcement point. Two-tier caching (in-process 30s + Redis 60s) keeps plan lookups out of the hot path while still reflecting plan upgrades within a predictable window.
  • Partition by userId for both correctness and performance. All of a user’s sessions living in one sorted set enables atomic operations without cross-shard transactions. Redis Cluster hash tags enforce this placement automatically.
  • Fail safely under Redis unavailability. A session registry outage should not take down streaming entirely. Either fail-open with local in-memory tracking (accepting a brief correctness window) or fail-closed with a clear error - never silently corrupt state.
  • The evicted session needs a signaling path. Storing recently evicted session IDs in a Redis set with a short TTL lets the heartbeat handler return HTTP 410 to the evicted device, enabling the client to show a meaningful “another device connected” message.

FAQ

Q: What happens if two devices connect for the same user at exactly the same millisecond?

Redis processes commands serially. Even if two Session Service nodes send the Lua script to the same Redis shard simultaneously, Redis queues them and executes them one at a time. The second script runs after the first has already incremented the count and potentially evicted a session. There is no concurrent execution inside Redis.

Q: Can a user with a Premium plan (4 devices) upgrade to a higher tier in the future? How does the limit change take effect?

Plan changes trigger a cache invalidation via Redis pub/sub to all Session Service nodes. Within 30 seconds (the in-process cache TTL), all new play requests use the updated limit. Existing sessions are not affected - they continue streaming uninterrupted. If a user downgrades (e.g., Premium to Standard), the new lower limit takes effect for the next session admission. Existing sessions above the new limit are not force-evicted; they expire naturally via TTL as users close apps.

Q: How do you handle the case where the same user opens 4 browser tabs on the same laptop?

This depends on product policy. If browser tabs share a device ID (same browser instance, same fingerprint), the Lua script will see it as one device reconnecting repeatedly and refresh the same session entry. If the product wants to count each tab as a separate session, the client must generate a unique session ID per tab and not persist it across tabs (using sessionStorage instead of localStorage). The fingerprint would need to include a tab-unique identifier.

Q: What is the risk of the evicted_sessions set growing unbounded?

The set has a 60-second TTL and is used only as a short-lived signal channel. Eviction notifications older than 60 seconds are not meaningful - if a device has not sent a heartbeat in 60 seconds, its session has already expired via the 90-second TTL anyway. The set is reset on every write via EXPIRE. In the worst case (extremely high eviction rate), the set might hold thousands of entries, but at ~30 bytes per session ID, this is negligible.

Q: How do you prevent a user from sharing their credentials across households?

Credential sharing is a separate (harder) problem from concurrent device limiting. Device limits prevent more than N simultaneous streams but do not prevent sequential sharing (one household watches in the morning, another in the evening). Netflix’s credential-sharing crackdown used IP geolocation, device registration, and periodic “are you at home?” verification - well beyond what a pure session manager handles. The session manager’s job is enforcement, not detection.

Q: How do you handle geo-distributed deployments where Redis latency from distant regions is high?

Regional Redis clusters with a primary region for the source of truth. Sessions are registered in the user’s home-region Redis. For playback requests from a different region (e.g., a traveler), the request is proxied to the home-region Session Service. The latency penalty is only on session start, not on heartbeats. Heartbeats can be handled by the local regional Session Service, which forwards the update to the home-region Redis asynchronously - accepting a 1-2 second eventual consistency window in the heartbeat timestamps.

Interview Questions

1. You have a race condition where two devices connect simultaneously for a user at their limit. Walk me through your solution.

The answer is the atomic Lua script in Redis. By executing count-check-evict-insert as a single script, Redis’s single-threaded execution model guarantees that no two scripts for the same user overlap. The interviewer is looking for you to identify that the race window is between the read (count) and the write (insert), and that the solution is to make the read-evaluate-write sequence atomic rather than introducing application-level locks.

2. Your heartbeat interval is 30 seconds and TTL is 90 seconds. A user complains their session was kicked even though they were actively watching. What could have caused this?

Possible causes: (a) a clock skew between Session Service nodes caused the heartbeat timestamp to appear older than it was, making the session look like the oldest; (b) the client’s heartbeat request was failing silently due to a network issue while the video was still playing from a buffer; (c) another device connected with the same fingerprint (fingerprint collision due to poor uniqueness properties); (d) the eviction was correct but the user was confused about how many streams they had open. Debugging steps: check heartbeat logs for 5xx responses, check Redis ZSCORE for the session to see its last recorded timestamp, check whether a fingerprint match was triggered.

3. Your product team wants to add a “watch together” feature where a group of users can co-watch and it counts as only one session slot. How would you modify the session registry?

Introduce a concept of a “session group” - a parent session ID that multiple devices can join. The sorted set score would track the most recent heartbeat from any member of the group. The plan limit would be checked against the number of active session groups, not the number of individual devices. The Lua script would need a new code path: join_session_group, which adds a device to an existing sorted set member (now representing a group) rather than creating a new entry. Implementation complexity: the group host’s departure triggers promotion of another member, requiring an additional leadership election step.

4. Redis goes down. What does your system do?

Immediate impact: all new session admits fail because the Lua script cannot execute. Heartbeats fail silently. Within 90 seconds, all sessions are technically “expired” from Redis’s perspective, but devices are still streaming because the video player buffers content. Recovery options: (a) fail-open - Session Service falls back to an in-memory session registry per node, accepting that concurrent counts are no longer globally consistent across nodes; (b) fail-closed - return HTTP 503 to new play requests, existing sessions continue streaming until their buffer exhausts. The right choice depends on the SLA. Most streaming platforms choose fail-open for existing sessions (keep streaming) and fail-closed for new session admits (prevent new starts) until Redis recovers.

5. How would you test the eviction logic for correctness, particularly the “no N+1 slip-through” guarantee?

Property-based testing with a load harness. The test fires N+1 concurrent play requests for a user with limit N, then asserts that exactly N sessions are in the Redis sorted set after all requests complete. Run this 10,000 times to stress the concurrency. In addition: a chaos test that restarts Redis mid-burst to verify the system recovers cleanly. A clock-skew test that deliberately sets application timestamps 2 seconds behind Redis server time to verify the Lua script uses Redis TIME correctly. A reconnect test that submits the same session ID 50 times concurrently and verifies only one entry exists in the sorted set after all complete.

Premium Content

Unlock the full article along with everything else in the archive — all in one place.

In-depth analysis Expert insights Full archive access
Unlock Full Article