Real-Time Architecture
The chat system uses a hybrid real-time model combining Supabase Postgres Changes, Broadcasts, and Presence on a single Realtime channel per episode.
Channel Structure
Each episode gets one shared channel: episode-chat:{episodeId}
Subscription Model
Authenticated Users (Hybrid)
Authenticated users receive messages through two channels simultaneously:
postgres_changes(INSERT onepisode_messages) - Reliable, RLS-enforced delivery triggered by database inserts- Broadcasts (
new_messageevent) - Low-latency delivery broadcast by the sender
A seenMessageIds Set prevents duplicates when both fire for the same message.
// Simplified subscription setup for authenticated users
channel
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'episode_messages',
filter: `episode_id=eq.${episodeId}`
},
(payload) => {
if (!seenMessageIds.has(payload.new.id)) {
seenMessageIds.add(payload.new.id);
onMessage(mapMessageFromDb(payload.new));
}
}
)
.on('broadcast', { event: 'new_message' }, (payload) => {
const msg = payload.payload;
if (!seenMessageIds.has(msg.id)) {
seenMessageIds.add(msg.id);
onMessage(msg);
}
});Guests (Broadcast Only)
Guests cannot receive postgres_changes because RLS blocks their access to episode_messages. They rely entirely on broadcasts.
To compensate for potential missed messages (e.g., during reconnection), guests use syncMissedMessages() which calls the get_guest_episode_messages RPC to fetch recent messages and reconcile with the store.
Broadcast Events
All events are broadcast on the same channel and received by all subscribers.
new_message
Sent after a message is successfully persisted to the database.
interface NewMessagePayload {
id: string;
episodeId: string;
userId: string | null;
guestId: string | null;
messageType: 'chat' | 'activity' | 'note';
senderName: string;
senderAvatarUrl: string | null;
content: string;
mentions: MentionData[];
createdAt: string;
}typing
Sent when a user starts or stops typing. Throttled to 500ms on the sender side.
interface TypingPayload {
userId?: string;
guestId?: string;
senderName: string;
isTyping: boolean;
}reaction_update
Sent after a reaction is toggled in the database.
interface ReactionUpdatePayload {
messageId: string;
emoji: string;
userId?: string;
guestId?: string;
senderName: string;
action: 'add' | 'remove';
}message_deleted
Sent after a message is deleted from the database.
interface MessageDeletedPayload {
messageId: string;
}Presence Tracking
Online status is tracked via the Supabase Realtime Presence API on the same channel.
Tracking
When subscribing, the client tracks its presence:
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
userId: presenceUser.userId,
guestId: presenceUser.guestId,
name: presenceUser.name,
avatarUrl: presenceUser.avatarUrl,
color: presenceUser.color
});
}
});Sync
On presence.sync events, the store is updated with all currently online users:
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
const users = Object.values(state)
.flat()
.map((p) => ({
userId: p.userId,
guestId: p.guestId,
name: p.name,
avatarUrl: p.avatarUrl,
color: p.color
}));
chatPresenceStore.updatePresence(users);
});Reconnection
Presence is re-tracked on channel reconnection to ensure the user remains visible as online after network interruptions.
Optimistic UI Pattern
Message Sending
Message Deletion
Reaction Toggle
Error Handling
| Scenario | Behavior |
|---|---|
| Send fails | Temp message removed from store, error surfaced to input component which restores content |
| Delete fails | Message restored at original position with reactions intact |
| Reaction fails | Optimistic update reverted silently |
| Rate limit hit | Error message displayed: "Too many messages. Try again in Xs." |
| Connection lost | Typing indicators expire naturally; presence auto-clears on timeout |
| Reconnect | Presence re-tracked; guests call syncMissedMessages() |
Related Documentation
- Service Layer - Function signatures and store details
- Database Schema - Tables and RLS policies
- Components - UI component behavior