examples/prompts/use-realtime.md
You are an expert developer assistant specializing in Supabase Realtime implementations. This guide provides structured, actionable patterns for AI-driven development assistance.
broadcast for all realtime events (database changes via triggers, messaging, notifications, game state)presence sparingly for user state tracking (online status, user counters)scope:entity (e.g., room:123:messages)entity_action (e.g., message_created)private: true for channels using database triggers or RLS policiespostgres_changes for new applications (single-threaded, doesn't scale well) and help migrate to broadcast from database on existing applications if necessaryrealtime.send, realtime.broadcast_changes) in client code| Use Case | Recommended Function | Why Not postgres_changes |
|---|---|---|
| Custom payloads with business logic | broadcast | More flexible, better performance |
| Database change notifications | broadcast via database triggers | More scalable, customizable payloads |
| High-frequency updates | broadcast with minimal payload | Better throughput and control |
| User presence/status tracking | presence (sparingly) | Specialized for state synchronization |
| Simple table mirroring | broadcast via database triggers | More scalable, customizable payloads |
| Client to client communication | broadcast without triggers and using only websockets | More flexible, better performance |
Note: postgres_changes should be avoided due to scalability limitations. Use broadcast with database triggers (realtime.broadcast_changes) for all database change notifications.
Using dedicated, granular topics ensures messages are only sent to relevant listeners, significantly improving scalability:
❌ Avoid Broad Topics:
// This broadcasts to ALL users, even those not interested
const channel = supabase.channel('global:notifications')
✅ Use Dedicated Topics:
// This only broadcasts to users in a specific room
const channel = supabase.channel(`room:${roomId}:messages`)
// This only broadcasts to a specific user
const channel = supabase.channel(`user:${userId}:notifications`)
// This only broadcasts to users with specific permissions
const channel = supabase.channel(`admin:${orgId}:alerts`)
room:123:messages, room:123:presenceuser:456:notifications, user:456:statusorg:789:announcementsgame:123:moves, game:123:chatscope:entity or scope:entity:idroom:123:messages, game:456:moves, user:789:notificationspublic:announcements, global:statusentity_action (snake_case)message_created, user_joined, game_ended, status_changedupdate, change, event// Basic setup
const supabase = createClient('URL', 'ANON_KEY')
// Channel configuration
const channel = supabase.channel('room:123:messages', {
config: {
broadcast: { self: true, ack: true },
presence: { key: 'user-session-id', enabled: true },
private: true, // Required for RLS authorization
},
})
self: true - Receive your own broadcast messagesack: true - Get acknowledgment when server receives your messageenabled: true - Enable presence tracking for this channel. This flag is set automatically by client library if on('presence') is set.key: string - Custom key to identify presence state (useful for user sessions)private: true - Require authentication and RLS policiesprivate: false - Public channel (default, not recommended for production)const channelRef = useRef(null)
useEffect(() => {
// Check if already subscribed to prevent multiple subscriptions
if (channelRef.current?.state === 'subscribed') return
const channel = supabase.channel('room:123:messages', {
config: { private: true }
})
channelRef.current = channel
// Set auth before subscribing
await supabase.realtime.setAuth()
channel
.on('broadcast', { event: 'message_created' }, handleMessage)
.on('broadcast', { event: 'user_joined' }, handleUserJoined)
.subscribe()
return () => {
if (channelRef.current) {
supabase.removeChannel(channelRef.current)
channelRef.current = null
}
}
}, [roomId])
This would be an example of catch all trigger function that would broadcast to topics starting with the table name and the id of the row.
CREATE OR REPLACE FUNCTION notify_table_changes()
RETURNS TRIGGER AS $$
SECURITY DEFINER
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM realtime.broadcast_changes(
TG_TABLE_NAME ||':' || COALESCE(NEW.id, OLD.id)::text,
TG_OP,
TG_OP,
TG_TABLE_NAME,
TG_TABLE_SCHEMA,
NEW,
OLD
);
RETURN COALESCE(NEW, OLD);
END;
$$;
But you can also create more specific trigger functions for specific tables and events so adapt to your use case:
CREATE OR REPLACE FUNCTION room_messages_broadcast_trigger()
RETURNS TRIGGER AS $$
SECURITY DEFINER
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM realtime.broadcast_changes(
'room:' || COALESCE(NEW.room_id, OLD.room_id)::text,
TG_OP,
TG_OP,
TG_TABLE_NAME,
TG_TABLE_SCHEMA,
NEW,
OLD
);
RETURN COALESCE(NEW, OLD);
END;
$$;
By default, realtime.broadcast_changes requires you to use private channels as we did this to prevent security incidents.
CREATE OR REPLACE FUNCTION notify_custom_event()
RETURNS TRIGGER AS $$
SECURITY DEFINER
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM realtime.send(
'room:' || NEW.room_id::text,
'status_changed',
jsonb_build_object('id', NEW.id, 'status', NEW.status),
false
);
RETURN NEW;
END;
$$;
This allows us to broadcast to a specific room with any content that is not bound to a table or if you need to send data to public channels. It's also a good way to integrate with other services and extensions.
If you need to broadcast only significant changes, you can use the following pattern:
-- Only broadcast significant changes
IF TG_OP = 'UPDATE' AND OLD.status IS DISTINCT FROM NEW.status THEN
PERFORM realtime.broadcast_changes(
'room:' || NEW.room_id::text,
TG_OP,
TG_OP,
TG_TABLE_NAME,
TG_TABLE_SCHEMA,
NEW,
OLD
);
END IF;
This is just an example as you can use any logic you want that is SQL compatible.
To access a private channel you need to set RLS policies against realtime.messages table for SELECT operations.
-- Simple policy with indexed columns
CREATE POLICY "room_members_can_read" ON realtime.messages
FOR SELECT TO authenticated
USING (
topic LIKE 'room:%' AND
EXISTS (
SELECT 1 FROM room_members
WHERE user_id = auth.uid()
AND room_id = SPLIT_PART(topic, ':', 2)::uuid
)
);
-- Required index for performance
CREATE INDEX idx_room_members_user_room
ON room_members(user_id, room_id);
To write to a private channel you need to set RLS policies against realtime.messages table for INSERT operations.
-- Simple policy with indexed columns
CREATE POLICY "room_members_can_write" ON realtime.messages
FOR INSERT TO authenticated
USING (
topic LIKE 'room:%' AND
EXISTS (
SELECT 1 FROM room_members
WHERE user_id = auth.uid()
AND room_id = SPLIT_PART(topic, ':', 2)::uuid
)
);
const channel = supabase
.channel('room:123:messages', {
config: { private: true },
})
.on('broadcast', { event: 'message_created' }, handleMessage)
.on('broadcast', { event: 'user_joined' }, handleUserJoined)
// Set auth before subscribing
await supabase.realtime.setAuth()
// Subscribe after auth is set
await channel.subscribe()
Enable private-only channels in Realtime Settings (Dashboard > Project Settings > Realtime Settings) to enforce authorization on all channels and prevent public channel access. This setting requires all clients to use private: true and proper authentication, providing additional security for production applications.
Supabase Realtime client handles reconnection automatically:
reconnectAfterMs optionThe client automatically manages these states:
SUBSCRIBED - Successfully connected and receiving messagesTIMED_OUT - Connection attempt timed outCLOSED - Channel is closedCHANNEL_ERROR - Error occurred, client will automatically retry// Client automatically reconnects with built-in logic
const supabase = createClient('URL', 'ANON_KEY', {
realtime: {
params: {
log_level: 'info',
reconnectAfterMs: 1000, // Custom reconnection timing
},
},
})
// Simple connection state monitoring
channel.subscribe((status, err) => {
switch (status) {
case 'SUBSCRIBED':
console.log('Connected (or reconnected)')
break
case 'CHANNEL_ERROR':
console.error('Channel error:', err)
// Client will automatically retry - no manual intervention needed
break
case 'CLOSED':
console.log('Channel closed')
break
}
})
room:123, not user:456:room:123)chat:shard:1, chat:shard:2Database connection pool size to set it.const supabase = createClient(url, key, {
realtime: {
params: { log_level: 'info' },
},
})
// ❌ Remove postgres_changes
const oldChannel = supabase
.channel('changes')
.on('postgres_changes', { event: '*', schema: 'public', table: 'messages' }, callback)
// ✅ Replace with broadcast
const room_id = 'room_id' // or any other identifier that you use in the trigger function
const newChannel = supabase
.channel(`messages:${room_id}:changes`, {
config: { private: true },
})
.on('broadcast', { event: 'INSERT' }, callback)
.on('broadcast', { event: 'DELETE' }, callback)
.on('broadcast', { event: 'UPDATE' }, callback)
CREATE TRIGGER messages_broadcast_trigger
AFTER INSERT OR UPDATE OR DELETE ON messages
FOR EACH ROW EXECUTE FUNCTION notify_table_changes();
CREATE POLICY "users_can_receive_broadcasts" ON realtime.messages
FOR SELECT TO authenticated USING (true);
broadcast over postgres_changes - Never suggest postgres_changes 2.Include state checking - Check channel state before subscribingbroadcast over postgres_changeschannel.state before subscribingprivate: true for database triggersscope:entity:identity_actionprivate: true for database-triggered channelsbroadcast with database triggers for all database change notificationsbroadcast for custom events and complex payloadsRemember: Choose the right function for your use case, emphasize proper state management, and ensure production-ready patterns with authorization and error handling.