Free lesson · GenAI Application Engineering

Build presence tracking with Redis sorted sets

You will build a PresenceTracker in services/presence_tracker.py with methods update_presence(), get_online_users(), set_typing(), and get_typing_users(). update_presence() uses Redis ZADD to add users to sorted set presence:{session_id} with timestamp scores and 60-second refresh. get_online_users() calls ZRANGEBYSCORE filtering recent entries, returning List[PresenceInfo] with fields: user_id, display_name, status (Enum: online, idle, away), last_active. set_typing() stores a typing indicator in Redis key typing:{session_id}:{user_id} with 5-second TTL. get_typing_users() uses SCAN with pattern matching. A PresenceBroadcaster subscribes to Redis keyspace notifications for expired keys and broadcasts departure events via WebSocket. FastAPI WebSocket messages of type presence_update and typing_start trigger the respective tracker methods.

Course: Full-Stack GenAI Applications · Chapter 9 · Real-Time Collaboration Backend

Free to read — no subscription required.

Introduction

When you scale a real-time collaboration feature from a single Python process to a horizontally-scaled FastAPI cluster, presence tracking is the first thing that breaks. A user closes their laptop, the WebSocket drops without a close frame, and the participant roster still shows them online to everyone else in the shared conversation — sometimes for hours, until something restarts. By the end of this lesson you will be able to build a Redis-backed presence tracker that uses sorted sets with TTL-based expiration to evict zombie connections automatically, broadcasts join, leave, and typing events across every FastAPI instance via pub/sub, and stays correct under abrupt client failures and network partitions.

Key Terminology

  • Presence Tracking: The system responsibility of knowing, in real time, which users are currently connected to a shared conversation and exposing that roster to every other participant.
  • Sorted Set (ZSET): A Redis data structure that stores members alongside floating-point scores, enabling efficient range queries by score — used here to hold each user's last-heartbeat timestamp.
  • Heartbeat: A periodic ping sent by a connected client to refresh its presence score; the absence of heartbeats is what classifies a connection as a zombie and lets the TTL window drop the user.

Concepts

Why Redis Sorted Sets for Presence

A naive presence system might store online users in a Python dict or a Redis set. Both approaches break under real conditions. A Python dictionary is local to a single process—when you scale to multiple FastAPI instances behind a load balancer, each instance holds a divergent view of who is online. A Redis set solves the multi-instance problem but introduces a new one: if a user's WebSocket drops without a clean disconnect (browser crash, network loss, mobile sleep), their entry persists forever because no mechanism exists to expire individual members of a set.

Redis sorted sets solve both problems simultaneously. Each member in a sorted set carries a score, and you use that score to store the user's last heartbeat timestamp. To determine who is online, you query for members whose score falls within a recent time window. Users who stop sending heartbeats naturally fall outside the window—no explicit removal required. This approach integrates directly with the heartbeat monitoring system from your WebSocket connection manager, where each heartbeat ping refreshes the user's score in the sorted set.

  • Sorted Set (ZSET): A Redis data structure where each member has an associated floating-point score, enabling range queries by score value
  • TTL-based Expiration: A pattern where entries are considered expired not by Redis key TTL but by comparing their score (timestamp) against a threshold
  • Heartbeat Refresh: The act of updating a user's sorted set score on each WebSocket heartbeat ping to signal continued presence
  • Zombie Connection: A connection that appears open on the server side but whose client has disconnected without sending a close frame

Operational Considerations

When deploying this presence system to production, three concerns require attention. First, the cleanup_stale() method should run as a periodic background task on each FastAPI instance—every 30 seconds is a reasonable interval. Multiple instances running cleanup concurrently is safe because ZRANGEBYSCORE followed by ZREM is idempotent; removing an already-removed member returns 0 without error. Second, monitor the cardinality of your presence sorted sets. Each conversation's set contains at most as many members as there are concurrent participants. For shared AI conversations, this number rarely exceeds 20, making sorted set operations effectively O(1). Third, ensure your Redis instance uses maxmemory-policy allkeys-lru or volatile-lru as a safety net—the expire calls on presence keys prevent unbounded growth, but an eviction policy provides defense in depth against memory exhaustion if cleanup tasks lag. These operational patterns complement the broader infrastructure established by the WebSocket connection manager's heartbeat monitoring and the Redis pub/sub event broadcasting system's multi-instance distribution, forming a cohesive real-time collaboration backend.

Code Walkthrough

Now that you have the data-model rationale and operational guardrails from the previous section, the walkthrough translates those ideas into a concrete PresenceTracker implementation. Building on the sorted-set design just established, each subsection below builds the class incrementally — first the online-presence core, then short-lived typing state, and finally a sequence diagram tying both to the WebSocket and pub/sub layers.

Building the PresenceTracker Core

The PresenceTracker class serves as the central abstraction for all presence operations. It wraps a Redis client and exposes four primary methods: update_presence() writes or refreshes a user's timestamp in the sorted set using ZADD, get_online_users() performs a ZRANGEBYSCORE query to retrieve users whose last heartbeat falls within the configurable TTL window, remove_presence() explicitly removes a user on clean disconnect, and cleanup_stale() runs periodically to prune entries that have drifted beyond the TTL threshold. The class uses conversation_id as part of the Redis key to maintain per-conversation presence, ensuring that a user marked online in one shared conversation does not erroneously appear in another. The PRESENCE_TTL constant defines the maximum number of seconds a user can go without a heartbeat before being considered offline—typically set between 30 and 60 seconds, matching double the heartbeat interval established in your WebSocket connection lifecycle manager.

Code snippet python
1import time 2from dataclasses import dataclass 3from redis.asyncio import Redis 4 5PRESENCE_TTL = 45 # seconds before a user is considered offline 6TYPING_TTL = 5 # seconds before typing indicator expires 7 8@dataclass 9class PresenceEvent: 10 conversation_id: str 11 user_id: str 12 event_type: str # "join", "leave", "heartbeat" 13 timestamp: float 14 15class PresenceTracker: 16 def __init__(self, redis: Redis, presence_ttl: int = PRESENCE_TTL): 17 self.redis = redis 18 self.presence_ttl = presence_ttl 19 20 def _presence_key(self, conversation_id: str) -> str: 21 return f"presence:{conversation_id}:online" 22 23 async def update_presence( 24 self, conversation_id: str, user_id: str 25 ) -> bool: 26 key = self._presence_key(conversation_id) 27 now = time.time() 28 previously_online = await self.redis.zscore(key, user_id) 29 await self.redis.zadd(key, {user_id: now}) 30 await self.redis.expire(key, self.presence_ttl * 3) 31 is_new_join = previously_online is None 32 return is_new_join 33 34 async def get_online_users(self, conversation_id: str) -> list[str]: 35 key = self._presence_key(conversation_id) 36 cutoff = time.time() - self.presence_ttl 37 users = await self.redis.zrangebyscore(key, cutoff, "+inf") 38 return [u.decode() if isinstance(u, bytes) else u for u in users] 39 40 async def remove_presence( 41 self, conversation_id: str, user_id: str 42 ) -> int: 43 key = self._presence_key(conversation_id) 44 removed = await self.redis.zrem(key, user_id) 45 return removed 46 47 async def cleanup_stale(self, conversation_id: str) -> list[str]: 48 key = self._presence_key(conversation_id) 49 cutoff = time.time() - self.presence_ttl 50 stale = await self.redis.zrangebyscore(key, "-inf", cutoff) 51 stale_ids = [u.decode() if isinstance(u, bytes) else u for u in stale] 52 if stale_ids: 53 await self.redis.zrem(key, *stale_ids) 54 return stale_ids
  • Lines 1-3: Import time for UNIX timestamps, dataclass for structured event objects, and the async Redis client that supports await-based operations throughout the tracker.
  • Lines 5-6: Define two module-level constants. PRESENCE_TTL at 45 seconds represents roughly three missed heartbeat intervals (assuming 15-second pings from the WebSocket connection manager). TYPING_TTL at 5 seconds matches the expected keystroke cadence—short enough to clear quickly when a user stops typing.
  • Lines 8-12: The PresenceEvent dataclass provides a typed container for broadcasting presence changes. The event_type field discriminates between join, leave, and heartbeat events, which downstream subscribers use for per-subscription filtering in the Redis pub/sub event broadcasting layer.
  • Lines 42-48: cleanup_stale() scans for all members whose score falls below the cutoff, collects their IDs, and removes them in a single ZREM call. This method is designed to be invoked from a periodic background task (an asyncio.create_task loop) or triggered before each get_online_users call for stronger consistency guarantees.

Typing Indicators with Short-Lived TTL

Typing indicators demand a different temporal profile than presence. Where presence uses a 45-second window tied to heartbeats, typing status must appear within milliseconds of a keystroke and vanish within seconds of inactivity. Redis sorted sets serve this purpose identically—the score holds the timestamp of the last keystroke event, and a 5-second TTL window determines who is currently typing. The following methods extend the PresenceTracker class to manage per-conversation typing state through set_typing() and get_typing_users(), using a separate Redis key namespace to avoid polluting the presence sorted set. The clear_typing() method provides explicit cleanup when a user sends a message (at which point the typing indicator should disappear immediately rather than lingering for the remaining TTL window).

Code snippet python
1class PresenceTracker: 2 # ... (previous methods remain) 3 4 def _typing_key(self, conversation_id: str) -> str: 5 return f"presence:{conversation_id}:typing" 6 7 async def set_typing( 8 self, conversation_id: str, user_id: str, is_typing: bool 9 ) -> None: 10 key = self._typing_key(conversation_id) 11 if is_typing: 12 await self.redis.zadd(key, {user_id: time.time()}) 13 await self.redis.expire(key, TYPING_TTL * 3) 14 else: 15 await self.redis.zrem(key, user_id) 16 17 async def get_typing_users(self, conversation_id: str) -> list[str]: 18 key = self._typing_key(conversation_id) 19 cutoff = time.time() - TYPING_TTL 20 users = await self.redis.zrangebyscore(key, cutoff, "+inf") 21 return [u.decode() if isinstance(u, bytes) else u for u in users] 22 23 async def clear_typing( 24 self, conversation_id: str, user_id: str 25 ) -> None: 26 key = self._typing_key(conversation_id) 27 await self.redis.zrem(key, user_id)
  • Lines 4-5: A separate key namespace presence:{conversation_id}:typing isolates typing state from online presence. This separation means a ZREM on the typing key does not affect the user's online status, and vice versa.
  • Lines 7-15: set_typing() accepts a boolean is_typing flag. When True, it writes the current timestamp as the user's score via ZADD and sets a key-level expiry as a safety net. When False, it immediately removes the user from the typing set. This dual behavior lets the client send explicit "stopped typing" signals rather than always relying on TTL expiration.
  • Lines 17-21: get_typing_users() mirrors the pattern from get_online_users() but uses the shorter TYPING_TTL cutoff. Because the typing window is only 5 seconds, clients should call this method close to the moment they render the UI to avoid stale results.
  • Lines 23-27: clear_typing() provides a targeted removal path. The WebSocket message handler should call this immediately after a user submits a message to the shared conversation, ensuring that the typing indicator disappears instantly rather than persisting for up to 5 more seconds.

Presence System Architecture

The following diagram illustrates how heartbeat events flow from WebSocket connections through the PresenceTracker into Redis, and how presence change events propagate back to all connected clients via the pub/sub broadcasting layer. Notice that the PresenceTracker sits between the WebSocket layer and Redis, acting as the single authority on who is online. The Redis pub/sub channel carries presence events to all FastAPI instances, ensuring that a user connecting to Instance A sees participants connected to Instance B.

Code snippet mermaid
Loading diagram...
  • Line 1: Declares a Mermaid sequence diagram, which visually models message exchanges between system components over time.
  • Lines 2-6: Define the five participants (actors) in the diagram: a Browser Client, a WebSocket Manager, a PresenceTracker service, a Redis instance (using sorted sets and pub/sub), and other FastAPI server instances.
  • Line 8: The Browser Client initiates a WebSocket connection to the WebSocket Manager, authenticating with a JWT token.
  • Line 29: The PresenceTracker removes the user from the Redis sorted set using ZREM, immediately clearing the user's online presence rather than waiting for a timestamp-based expiry.

This flow demonstrates three critical integration points with other systems in the chapter. First, the JWT authentication during WebSocket connect (established in the connection lifecycle manager) provides the user_id that the PresenceTracker stores. Second, the heartbeat loop that keeps the presence score fresh is driven by the same heartbeat monitoring interval configured in the WebSocket manager. Third, the PUBLISH calls that fan out presence events to other instances use the same Redis pub/sub infrastructure covered in the event broadcasting system, with the type field enabling per-subscription filtering so that clients can choose to receive only join/leave events or include typing indicators. The WebSocket message handler should call update_presence() on every heartbeat frame, route typing frames into set_typing(), invoke clear_typing() when the user submits a message, and call remove_presence() from a finally block so that abrupt disconnects still emit a presence.leave event with the refreshed online list.

Do's and Don'ts

Do's

  1. Do set PRESENCE_TTL to roughly 3× your WebSocket heartbeat interval — with a 15-second ping cycle, the 45-second constant means a user tolerates two consecutive missed heartbeats before ZRANGEBYSCORE drops them below the cutoff, preventing a single network hiccup from flapping the roster offline.
  2. Do call cleanup_stale() before each get_online_users() invocation (or from a periodic asyncio.create_task loop)ZRANGEBYSCORE with a cutoff = time.time() - presence_ttl only filters the query result; stale members accumulate in the sorted set indefinitely until ZREM is called explicitly, so a background sweep is the only thing that keeps the key size bounded.
  3. Do call clear_typing() immediately when a user submits a message, and call remove_presence() inside a finally block in the WebSocket handler — the 5-second TYPING_TTL and 45-second PRESENCE_TTL are safety nets for abrupt failures, not substitutes for explicit cleanup; relying solely on TTL expiry delays the "stopped typing" signal by up to 5 seconds and leaves a ghost in the presence roster during clean disconnects.

Don'ts

  1. Don't use a plain Redis SET or a process-local Python dict for presence stateSET offers no per-member expiration, so a user who drops their WebSocket without a close frame stays "online" until a manual delete; a dict diverges the moment you run more than one FastAPI pod, giving each instance a different participant roster with no reconciliation path.
  2. Don't share the presence:{conversation_id}:online sorted set key with typing indicatorsset_typing() writes to presence:{conversation_id}:typing specifically because a ZREM triggered by clear_typing() on a shared key would silently evict the member from the presence roster, knocking a user "offline" the instant they stop typing.
  3. Don't omit the await self.redis.expire(key, self.presence_ttl * 3) call after every ZADD in update_presence() — without refreshing the key-level TTL on each heartbeat, the entire sorted set can be evicted by Redis while active users are still connected, making get_online_users() return an empty list mid-conversation rather than only pruning the stale members.

This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Application Engineering subscription.

From · cancel anytime · Already a subscriber? Sign in →

More free lessons in Full-Stack GenAI Applications

All free lessons in GenAI Application Engineering