Skip to content

Supabase Realtime

Honeycomb uses Supabase Realtime extensively to deliver live updates without polling. There are three primary patterns: Postgres Changes for message delivery, Presence for online/typing state, and notification channels for unread counts.

Each channel follows a {feature}:{id} naming scheme:

ChannelPurpose
room:{roomId}Room messages (Postgres Changes on ext_honeycomb.room_messages)
dm:{conversationId}Direct messages (Postgres Changes on ext_honeycomb.direct_messages)
chat:{chatId}Platform messaging (Postgres Changes on public.messages)
typing:{chatId}Typing indicators (Presence)
notifications:{userId}Unread notification count (Postgres Changes on public.notifications)

The most common pattern subscribes to INSERT events on a messages table filtered by a room or conversation ID.

From src/routes/(app)/honeycomb/rooms/[slug]/+page.svelte:

const channel = supabase
.channel(`room:${room.id}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "ext_honeycomb",
table: "room_messages",
filter: `room_id=eq.${room.id}`,
},
async (payload) => {
const newMsg = payload.new as RoomMessage;
if (liveMessages.some((m) => m.id === newMsg.id)) return;
// Resolve sender profile from cache or DB
newMsg.sender = await resolveSender(newMsg.sender_id);
liveMessages = [...liveMessages, newMsg];
},
)
.subscribe();

Every handler checks whether the incoming message ID already exists in the local array before appending. This prevents duplicates when the same user has multiple subscriptions or when optimistic updates are in play.

The chat-store implements optimistic message sending:

  1. addOptimistic(content) inserts a temporary message with a crypto.randomUUID() ID into the local array.
  2. When the real INSERT event arrives for the current user, the optimistic message is replaced with the server version.
  3. The pendingOptimisticIds set tracks which temporary IDs are awaiting confirmation.

Presence channels use Supabase’s built-in presence feature to track who is currently typing.

From src/lib/modules/messaging/components/typing-indicator.svelte:

channel = supabase.channel(`typing:${chatId}`, {
config: { presence: { key: currentUserId } },
});
channel
.on("presence", { event: "sync" }, () => {
const state = channel.presenceState();
typingUsers = Object.entries(state)
.filter(([key]) => key !== currentUserId)
.filter(([, presences]) => presences[0]?.typing)
.map(([, presences]) => presences[0]?.name ?? "Someone");
})
.subscribe(async (status) => {
if (status === "SUBSCRIBED") {
await channel.track({ typing: false, name: currentUserName });
}
});
  • startTyping() calls channel.track({ typing: true, name }) and sets a 3-second timeout that automatically reverts to false.
  • stopTyping() immediately tracks { typing: false } and clears the timeout.
  • The UI renders bouncing dots with the names of users who are currently typing.

The global presence state lives in src/lib/modules/honeycomb/presence/stores/presence-state.svelte.ts and uses Svelte 5 runes:

let onlineUsers = $state<Map<string, PresenceUser>>(new Map());
let typingUsers = $state<Map<string, TypingState>>(new Map());

Key methods:

MethodDescription
setUserOnline(user)Add user to the online map
setUserOffline(userId)Remove user from the online map
setTyping(state)Update typing state keyed by user_id:room_id or user_id:conversation_id
isOnline(userId)Check if a user is currently online
getTypingInRoom(roomId)Get all users typing in a room
getTypingInConversation(id)Get all users typing in a DM conversation
type PresenceUser = {
user_id: string;
username: string;
avatar_url: string | null;
status: "online" | "idle" | "offline";
last_seen: string | null;
};
type TypingState = {
user_id: string;
room_id: string | null;
conversation_id: string | null;
is_typing: boolean;
updated_at: string;
};

The notification store (src/lib/modules/notifications/stores/notification-store.svelte.ts) subscribes to new notification inserts to maintain a live unread count:

channel = supabase
.channel(`notifications:${userId}`)
.on("postgres_changes", {
event: "INSERT",
schema: "public",
table: "notifications",
filter: `notifiable_id=eq.${userId}`,
}, () => { unreadCount++; })
.subscribe();

All channel subscriptions follow the same lifecycle pattern:

onMount(() => {
const channel = supabase.channel("...").on(...).subscribe();
return () => {
supabase.removeChannel(channel);
};
});
FilePurpose
src/routes/(app)/honeycomb/rooms/[slug]/+page.svelteRoom message realtime subscription
src/routes/(app)/honeycomb/messages/[conversationId]/+page.svelteDM realtime subscription
src/lib/modules/messaging/stores/chat-store.svelte.tsPlatform chat store with optimistic updates
src/lib/modules/messaging/components/typing-indicator.sveltePresence-based typing indicator
src/lib/modules/honeycomb/presence/stores/presence-state.svelte.tsGlobal presence state (Svelte 5 runes)
src/lib/modules/notifications/stores/notification-store.svelte.tsLive unread notification count