Skip to content

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:

  1. postgres_changes (INSERT on episode_messages) - Reliable, RLS-enforced delivery triggered by database inserts
  2. Broadcasts (new_message event) - Low-latency delivery broadcast by the sender

A seenMessageIds Set prevents duplicates when both fire for the same message.

typescript
// 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.

typescript
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.

typescript
interface TypingPayload {
	userId?: string;
	guestId?: string;
	senderName: string;
	isTyping: boolean;
}

reaction_update

Sent after a reaction is toggled in the database.

typescript
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.

typescript
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:

typescript
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:

typescript
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

ScenarioBehavior
Send failsTemp message removed from store, error surfaced to input component which restores content
Delete failsMessage restored at original position with reactions intact
Reaction failsOptimistic update reverted silently
Rate limit hitError message displayed: "Too many messages. Try again in Xs."
Connection lostTyping indicators expire naturally; presence auto-clears on timeout
ReconnectPresence re-tracked; guests call syncMissedMessages()

Internal documentation - Not for public distribution