Free lesson · GenAI Application Engineering
Build an event broadcast system with Redis pub/sub
You will build an EventBroadcaster in services/event_broadcaster.py with methods publish_event(), subscribe(), and unsubscribe(). Events use an EventType enum (new_message, typing_indicator, presence_update, ai_response_chunk, session_joined, session_left) wrapped in BroadcastEvent Pydantic model with fields: event_type, session_id, user_id, payload (dict), timestamp, instance_id. publish_event() serializes to JSON and publishes to Redis channel events:{session_id}. An EventSubscriber background task calls SUBSCRIBE and listens for messages. EventRouter filters events per WebSocket connection using SubscriptionFilter (event_types: Set[EventType], session_ids: Set[str]). Multi-instance support works via shared Redis channels. FastAPI WebSocket subscribe messages register client filters for targeted event forwarding.
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 backend beyond a single process, every WebSocket connection lives on exactly one FastAPI instance — and an in-memory event bus inside that process can no longer reach the collaborators connected elsewhere. A user typing on Instance A never triggers an indicator on Instance B, presence updates go stale, and "live" features silently degrade the moment you add a second replica. By the end of this lesson you'll be able to design a Redis pub/sub-based event broadcast system that fans out typed events — new messages, typing indicators, presence updates, and LLM stream chunks — across multiple FastAPI instances to all subscribed WebSocket clients in a shared conversation session, applying echo suppression and per-subscription filters so each client receives exactly the events it cares about.
Key Terminology
- Broadcast event: A typed payload (e.g.,
NEW_MESSAGE,TYPING_INDICATOR,PRESENCE_UPDATE) carryingsession_id,sender_id, and a timestamp, published once and delivered to every subscribed instance. - Session channel: A Redis pub/sub channel namespaced by session ID (e.g.,
broadcast:session:abc123) that scopes event fan-out to the participants of one shared conversation. - Echo suppression: The filter that drops events whose
sender_idmatches the local subscriber's user ID, preventing a user from receiving their own actions back through the broadcast loop.
Concepts
Why Redis Pub/Sub Over Streams or Polling
Before diving into implementation, understand why Redis pub/sub fits this use case better than alternatives. Redis Streams provide durability and consumer groups—features you need for message queuing but not for ephemeral real-time events. A typing indicator that arrives 30 seconds late is worse than one that never arrives. Pub/sub delivers fire-and-forget semantics with sub-millisecond latency within a Redis instance. The tradeoff is clear: if an instance is temporarily disconnected, it misses events published during that window. For presence updates, typing indicators, and new message notifications, this is acceptable because the next event naturally corrects state. Contrast this with the conversation message history itself, which uses vector clocks and Redis-backed persistence (covered in another goal) precisely because durability matters there. The event broadcast system complements that durable layer by handling the transient notification path.
Integration with Presence and Conversation Systems
The event broadcaster is not a standalone system—it is the distribution layer that the presence tracker and the concurrent message handler publish through. When the presence tracker detects a TTL expiration in the Redis sorted set, it publishes a PRESENCE_UPDATE event via the broadcaster. When the conversation state lock is released after an LLM response completes, the concurrent handler publishes an LLM_STREAM_END event. The typing indicator system from another goal publishes TYPING_INDICATOR events at a throttled rate (no more than once per second per user) to avoid flooding the pub/sub channel.
Failure Modes and Resilience
Redis pub/sub has no built-in retry or message persistence. If the Redis connection drops, the _listener_loop raises a ConnectionError that you must handle by reconnecting and re-subscribing to all active channels. A production-grade broadcaster wraps the listener loop in a retry decorator that exponentially backs off on connection failures, re-issues SUBSCRIBE commands for every channel in _active_channels, and logs a warning so the monitoring system can alert on prolonged disconnections. During the reconnection window, events are lost—but because the presence system uses Redis sorted sets with TTL and conversation state uses vector clocks, the system self-heals: the next presence heartbeat or message sync corrects any stale state caused by missed events. This eventual consistency model is a deliberate architectural choice that trades rare, transient inconsistency for the low latency and simplicity of pub/sub over heavier alternatives like Redis Streams with consumer groups.
Code Walkthrough
The Typed Event Model
Every event flowing through the broadcast system carries a type discriminator that subscribers use for filtering. Without typed events, every subscriber processes every event and discards irrelevant ones client-side—wasting bandwidth and CPU on JSON parsing. The EventType enum and BroadcastEvent data class establish a contract between publishers and subscribers that the rest of the system depends on.
The following code defines the EventType enum with members covering all real-time event categories in the collaboration system, and the BroadcastEvent dataclass that wraps every event payload with metadata including event_type, session_id, sender_id, and a Unix timestamp. The to_json and from_json methods handle serialization for Redis transport, ensuring that enum values survive the round trip through string-based pub/sub channels.
Code snippet python
1import enum 2import json 3import time 4from dataclasses import dataclass, field, asdict 5from typing import Any, Optional 6 7class EventType(str, enum.Enum): 8 NEW_MESSAGE = "new_message" 9 TYPING_INDICATOR = "typing_indicator" 10 PRESENCE_UPDATE = "presence_update" 11 SESSION_JOIN = "session_join" 12 SESSION_LEAVE = "session_leave" 13 LLM_STREAM_CHUNK = "llm_stream_chunk" 14 LLM_STREAM_END = "llm_stream_end" 15 CONFLICT_RESOLVED = "conflict_resolved" 16 17@dataclass 18class BroadcastEvent: 19 event_type: EventType 20 session_id: str 21 sender_id: str 22 payload: dict[str, Any] = field(default_factory=dict) 23 timestamp: float = field(default_factory=time.time) 24 origin_instance: Optional[str] = None 25 26 def to_json(self) -> str: 27 data = asdict(self) 28 data["event_type"] = self.event_type.value 29 return json.dumps(data) 30 31 @classmethod 32 def from_json(cls, raw: str) -> "BroadcastEvent": 33 data = json.loads(raw) 34 data["event_type"] = EventType(data["event_type"]) 35 return cls(**data)
- Lines 1-4: Import
enumfor the type discriminator,jsonfor Redis serialization,timefor Unix timestamps, and dataclass utilities for the event model. - Lines 5-6: Import
Anyfor the flexible payload type andOptionalfor the nullableorigin_instancefield. - Lines 9-17: Define
EventTypeinheriting from bothstrandenum.Enum, making each member directly serializable as a string. The eight event types cover messages, typing, presence, session membership, LLM streaming, and conflict resolution from the vector clock system. - Lines 20-26: The
BroadcastEventdataclass bundles the event type withsession_id(which conversation this event belongs to),sender_id(who triggered it), a flexiblepayloaddict, an auto-generatedtimestamp, and anorigin_instanceidentifier used to prevent echo loops. - Lines 28-31:
to_jsonconverts the dataclass to a dict viaasdict, replaces theEventTypeenum with its string.valuefor JSON compatibility, and returns a serialized string. - Lines 33-37:
from_jsonis a classmethod that deserializes the JSON string, reconstructs theEventTypeenum from the string value, and unpacks everything into a newBroadcastEventinstance.
Multi-Instance Event Flow
Before examining the broadcaster implementation, visualize how events traverse from one user's action to another user's WebSocket across different server instances. The Redis pub/sub layer sits between the publishing instance and all subscribing instances, including the publisher itself.
This sequence diagram traces a cross-instance message delivery path where User A's WebSocket payload flows through FastAPI Instance 1, gets persisted with a vector clock for conflict-free ordering, then fans out via PUBLISH session:{id} on Redis Pub/Sub. Both local and remote subscribers receive the event_json, but each instance applies filtering logic—Instance 1 skips the sender to avoid echo, while Instance 2 matches the EventType and routes the new_message event to User B's WebSocket. This pub/sub fan-out pattern ensures horizontal scaling without sticky sessions.
Code snippet mermaid
Loading diagram...
- Line 1: Declares this as a Mermaid sequence diagram, used to visualize interactions between participants over time.
- Lines 2-6: Define the five participants (actors) in the system: User A connected to FastAPI Instance 1, a Redis Pub/Sub message broker in the middle, FastAPI Instance 2, and User B connected to Instance 2—representing a horizontally scaled WebSocket architecture.
- Line 8: User A sends a chat message over a WebSocket connection to FastAPI Instance 1, initiating the message flow.
- Line 9: Instance 1 processes the incoming message locally and persists it using a vector clock for conflict-free ordering in a distributed environment.
- Line 10: Instance 1 publishes the message as a JSON event to a Redis Pub/Sub channel namespaced by session ID, making it available to all subscribed instances.
- Lines 11-12: Redis delivers the published event to two subscribers—Instance 1 (the local publisher itself) and Instance 2 (a remote instance), shown with dashed arrows to indicate asynchronous push delivery.
- Line 13: Instance 1 receives its own published event but filters it out, skipping the sender (User A) since they already have the message locally—preventing duplicate delivery.
- Line 14: Instance 2 receives the event, filters it by EventType, and routes it to the appropriate recipient (User B) based on the event metadata.
- Line 15: Instance 2 pushes the new message event to User B over their WebSocket connection, completing the cross-instance real-time message delivery.
Instance 1 publishes the event after processing User A's message. Redis delivers the event to all subscribers on the session:{id} channel, including Instance 1 itself. Instance 1's local filter skips re-delivery to User A (the sender), while Instance 2's filter matches User B's subscription and forwards the event over their WebSocket. This echo-suppression is handled by comparing sender_id against each subscriber's user ID.
The EventBroadcaster Service
The core service manages Redis pub/sub connections, maintains local subscription registries, and routes incoming events through per-subscriber filter functions. The EventBroadcaster class uses a dedicated redis.asyncio pub/sub connection separate from the main Redis connection pool, because Redis pub/sub connections enter a special mode where they can only execute SUBSCRIBE, UNSUBSCRIBE, and PING commands.
The following implementation defines the EventBroadcaster class with publish_event() for sending events, subscribe() for registering local WebSocket handlers with optional EventType filters, unsubscribe() for cleanup, and a _listener_loop() that runs as a background asyncio.Task dispatching incoming pub/sub messages to matching local subscribers. The SubscriptionFilter callable type alias defines the per-subscriber filtering contract.
Code snippet python
1import asyncio 2import logging 3import uuid 4from collections import defaultdict 5from typing import Callable, Awaitable 6 7import redis.asyncio as aioredis 8 9from .events import BroadcastEvent, EventType 10 11logger = logging.getLogger(__name__) 12SubscriptionFilter = Callable[[BroadcastEvent], bool] 13EventHandler = Callable[[BroadcastEvent], Awaitable[None]] 14 15class EventBroadcaster: 16 def __init__(self, redis_url: str, instance_id: str | None = None): 17 self._redis = aioredis.from_url(redis_url) 18 self._pubsub = self._redis.pubsub() 19 self._instance_id = instance_id or uuid.uuid4().hex[:12] 20 self._subscribers: dict[str, dict[str, tuple[EventHandler, SubscriptionFilter | None]]] = defaultdict(dict) 21 self._active_channels: set[str] = set() 22 self._listener_task: asyncio.Task | None = None 23 24 def _channel_key(self, session_id: str) -> str: 25 return f"broadcast:session:{session_id}" 26 27 async def start(self) -> None: 28 self._listener_task = asyncio.create_task(self._listener_loop()) 29 logger.info("EventBroadcaster started on instance %s", self._instance_id) 30 31 async def publish_event(self, event: BroadcastEvent) -> int: 32 event.origin_instance = self._instance_id 33 channel = self._channel_key(event.session_id) 34 receivers = await self._redis.publish(channel, event.to_json()) 35 logger.debug("Published %s to %s (%d receivers)", event.event_type.value, channel, receivers) 36 return receivers 37 38 async def subscribe( 39 self, session_id: str, handler: EventHandler, 40 event_filter: SubscriptionFilter | None = None, 41 ) -> str: 42 sub_id = uuid.uuid4().hex[:16] 43 channel = self._channel_key(session_id) 44 self._subscribers[channel][sub_id] = (handler, event_filter) 45 if channel not in self._active_channels: 46 await self._pubsub.subscribe(channel) 47 self._active_channels.add(channel) 48 logger.info("Subscribed to Redis channel %s", channel) 49 return sub_id 50 51 async def unsubscribe(self, session_id: str, sub_id: str) -> None: 52 channel = self._channel_key(session_id) 53 self._subscribers[channel].pop(sub_id, None) 54 if not self._subscribers[channel]: 55 await self._pubsub.unsubscribe(channel) 56 self._active_channels.discard(channel) 57 del self._subscribers[channel] 58 59 async def _listener_loop(self) -> None: 60 try: 61 async for message in self._pubsub.listen(): 62 if message["type"] != "message": 63 continue 64 try: 65 event = BroadcastEvent.from_json(message["data"]) 66 except (json.JSONDecodeError, KeyError, ValueError) as exc: 67 logger.warning("Malformed broadcast event: %s", exc) 68 continue 69 channel = message["channel"] 70 if isinstance(channel, bytes): 71 channel = channel.decode() 72 subs = list(self._subscribers.get(channel, {}).items()) 73 for sub_id, (handler, evt_filter) in subs: 74 if evt_filter is not None and not evt_filter(event): 75 continue 76 try: 77 await handler(event) 78 except Exception: 79 logger.exception("Handler %s failed for event %s", sub_id, event.event_type.value) 80 except asyncio.CancelledError: 81 logger.info("Listener loop cancelled, cleaning up") 82 finally: 83 await self._pubsub.unsubscribe() 84 85 async def shutdown(self) -> None: 86 if self._listener_task: 87 self._listener_task.cancel() 88 await asyncio.gather(self._listener_task, return_exceptions=True) 89 await self._pubsub.close() 90 await self._redis.close()
- Lines 1-8: Import
asynciofor the background listener task,uuidfor generating unique subscription and instance IDs,defaultdictfor automatic channel-to-subscribers mapping, andredis.asynciofor non-blocking Redis pub/sub operations. - Lines 11-13: Define type aliases:
SubscriptionFilteris a callable accepting aBroadcastEventand returning a bool indicating whether the subscriber wants this event, andEventHandleris anasynccallable that processes accepted events. - Lines 17-23: The constructor creates a Redis client and a dedicated pub/sub object from it. The
_subscribersdict maps channel names to dicts ofsub_id → (handler, filter)tuples. The_active_channelsset tracks which Redis channels this instance is subscribed to, preventing duplicateSUBSCRIBEcommands. - Lines 25-26:
_channel_keynamespaces channels by session ID, so events for different conversations never cross-pollinate. - Lines 28-30:
start()launches the_listener_loopas a backgroundasyncio.Task. This task runs for the entire lifetime of the FastAPI instance. - Lines 32-37:
publish_event()stamps the event with this instance's ID (for echo detection) and calls RedisPUBLISH. Thereturnvalue is the number of Redis-level subscribers that received the message—useful for monitoring but not for delivery guarantees. - Lines 39-50:
subscribe()generates a uniquesub_id, registers the handler and optional filter, and issues a RedisSUBSCRIBEonly if this is the first local subscriber for that channel. Multiple local WebSocket connections watching the same session share one Redis subscription. - Lines 52-57:
unsubscribe()removes the subscriber entry and, if no local subscribers remain for the channel, issues a RedisUNSUBSCRIBEto stop receiving messages—preventing memory leaks and unnecessary network traffic. - Lines 59-78:
_listener_loop()is the central dispatch loop. It iterates over pub/sub messages, skips non-message types (like subscription confirmations), deserializes the event, decodes the channel name from bytes if necessary, and iterates over all local subscribers for that channel. Each subscriber's filter is checked: if the filter returns False, the event is skipped for that subscriber. Handler exceptions are caught and logged rather than propagated, preventing one broken handler from blocking the entire loop. - Lines 79-80:
asyncio.CancelledErroris caught cleanly so that shutdown triggers a graceful unsubscribe from all channels. - Lines 82-86:
shutdown()cancels the listener task, awaits its completion withreturn_exceptions=Trueto suppress the cancellation error, and closes both the pub/sub and Redis connections.
Per-Subscription Filtering in Practice
The filter callable registered alongside each subscribe() is where per-subscriber routing logic lives. When a WebSocket connection joins a session, the connection manager constructs a closure that captures the joining user's user_id and the set of EventType values that client cares about, then passes it as event_filter. Inside _listener_loop, every incoming event is run through that closure: it returns False when event.sender_id == user_id (echo suppression — the sender already has the event locally) or when event.event_type is not in the accepted set (the client doesn't care about this category), and True otherwise. Only events that pass the filter are dispatched to the matching route_to_websocket handler, which fetches the live WebSocket from the connection manager and forwards event_type, payload, sender_id, and timestamp as a JSON frame; if the user disconnected between filter and dispatch, the handler returns silently. The sub_id returned by subscribe() is stored against the WebSocket session so that the connection manager can call unsubscribe() during disconnect cleanup, releasing the Redis subscription when the last local listener for a channel goes away.
You'll know the broadcaster works when you connect two clients to the same session_id on different FastAPI instances, send a message from Client A, and Client B receives a new_message frame within sub-second latency while Client A does NOT receive its own event back.
Do's and Don'ts
Do's
- ✓Do declare
EventTypewith dual inheritance from bothstrandenum.Enum— this makes each member a native string at runtime, soto_json'sdata["event_type"] = self.event_type.valuepasses cleanly throughjson.dumps, andfrom_jsoncan reconstruct the enum withEventType(data["event_type"])on the subscriber side. A plainenum.Enumwithoutstrraises aTypeErrorinjson.dumpsbecause enum members are not natively JSON-serializable, breaking the Redis round-trip entirely. - ✓Do populate
origin_instanceon everyBroadcastEventand check it before routing to local WebSocket connections — Redis pub/sub delivers a published message back to the publishing instance's own subscriber; filtering onorigin_instanceis what suppresses that echo and prevents the sender from receiving a duplicate of their own event. Omitting this field means every publish results in an extra delivery to the originating client with no way to detect it. - ✓Do namespace Redis pub/sub channels by
session_id(e.g.,session:{session_id}) — scoping each instance's subscription to its active conversation sessions limits deserialization and routing work to events that instance actually needs to handle, rather than consuming the entire cluster's event stream. A flat global channel forces every replica to parse everyLLM_STREAM_CHUNKandTYPING_INDICATORfrom every session cluster-wide.
Don'ts
- ✗Don't skip the
EventTypeenum reconstruction infrom_json— afterjson.loads,data["event_type"]is a plainstr; callingcls(**data)without first converting it viaEventType(data["event_type"])leavesBroadcastEvent.event_typeholding a string, silently breaking every subscriber filter that comparesevent.event_type == EventType.TYPING_INDICATORusing enum equality, since"typing_indicator" == EventType.TYPING_INDICATORevaluates toTruefor astr-enum but the type contract is violated for any code doingisinstanceor pattern-matching. - ✗Don't defer all per-subscription filtering to the WebSocket send layer — if every FastAPI instance fans out all eight
EventTypevalues to every local subscriber and discards irrelevant ones only after full JSON deserialization, high-frequency events likeLLM_STREAM_CHUNKimpose unnecessary parse and dispatch overhead on every replica for every session; apply theEventTypediscriminator at the delivery routing step, before any WebSocketsendis attempted, so subscribers receive only the event categories they registered for. - ✗Don't publish all session events to a single shared Redis channel — collapsing all conversation traffic into one channel forces every FastAPI instance to receive, deserialize, and discard events for sessions it has no active WebSocket connections for, coupling each replica's CPU load to cluster-wide traffic volume instead of its own active sessions; use the
session:{session_id}channel shape shown in the sequence diagram so each instance subscribes only to the sessions it hosts.
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
More free lessons in Full-Stack GenAI Applications
- Ch 3Implement Anthropic prompt caching with cache_control markers
- Ch 8Build an MCP server exposing business logic as tools
- Ch 8Build a Pydantic AI agent with typed tools and DI
- Ch 8Build a Google ADK agent with MCP + multi-agent delegation
- Ch 9Build an event broadcast system with Redis pub/subYou are here
- Ch 10Build Llama Guard 4 content classifier
- Ch 14Build a semantic cache with Redis + embedding similarity