Introduction
Real-time features are table stakes for modern applications—collaborative docs, live dashboards, chat, notifications, and presence indicators are expected by users. Supabase makes real-time easy with its built-in Realtime service, but at scale, costs can spiral out of control.
A typical mistake: subscribing to entire tables or using broadcast channels without filtering, resulting in thousands of unnecessary messages propagating through WebSocket connections. One production application reduced their Supabase Realtime costs from $3,600/month to $972/month (73% reduction) by implementing the optimizations in this article.
This guide provides battle-tested strategies to minimize real-time costs while maintaining performance:
- Selective subscriptions using Postgres Row Level Security (RLS)
- Efficient LISTEN/NOTIFY patterns to reduce database load
- Broadcast channel optimization for collaborative features
- Connection pooling for WebSocket management
- Client-side throttling to prevent message floods
By the end, you’ll understand exactly how Supabase Realtime works under the hood and how to architect cost-efficient real-time systems.
Key Concepts
How Supabase Realtime Works
Supabase Realtime is built on three core technologies:
- Postgres LISTEN/NOTIFY: Database-level pub/sub for row changes
- WebSockets: Persistent client connections for bidirectional communication
- Phoenix Channels: Elixir framework managing subscriptions and broadcasts
When you subscribe to a table, here’s what happens:
// Client subscribes to 'messages' table
const channel = supabase
.channel('messages')
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'messages'
}, handleChange)
.subscribe();
Behind the scenes:
- Client opens WebSocket connection to Realtime server
- Realtime server executes
LISTENon Postgres channel - Postgres triggers send
NOTIFYfor every INSERT/UPDATE/DELETE - Realtime server filters changes based on RLS policies
- Matching changes sent to subscribed clients over WebSocket
Cost drivers:
- WebSocket connection count (scales with concurrent users)
- Database
NOTIFYvolume (triggers on every change) - CPU cycles filtering and routing messages
- Bandwidth for message payloads
The Cost Problem at Scale
Consider a chat application with 10,000 active users:
Naive implementation (subscribe to all messages):
// ❌ Every user subscribes to entire messages table
const channel = supabase
.channel('all-messages')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages'
}, handleNewMessage)
.subscribe();
What happens:
- 1 message sent = 10,000 NOTIFY events generated
- Realtime server processes 10,000 messages
- RLS filters down to relevant users (but damage is done)
- 100 messages/second = 1,000,000 events/second processed
Cost impact: Realtime server CPU at 95%, $3,600/month for Pro plan.
Optimized implementation (selective subscriptions):
// ✅ Subscribe only to user's channels
const channel = supabase
.channel(`room:${roomId}`)
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}` // Filter at subscription level
}, handleNewMessage)
.subscribe();
What happens:
- 1 message sent = 50 NOTIFY events (only users in room)
- Realtime server processes 50 messages
- 100 messages/second = 5,000 events/second (200x reduction)
Cost impact: Realtime server CPU at 12%, $972/month (73% savings).
Understanding Realtime Pricing
Supabase charges for Realtime based on:
-
Concurrent connections: Number of active WebSocket connections
- Free tier: 200 concurrent connections
- Pro: 500 concurrent ($25/month base)
- Team: Unlimited ($599/month base)
-
Message volume: Total messages processed per month
- Free tier: 2 million messages
- Pro: 5 million included, $2.50 per additional million
- Team: 50 million included
-
Bandwidth: Data transferred over WebSockets
- Charged per GB after included quota
Key insight: Reducing message volume has the highest ROI for cost optimization.
Technical Deep Dive
Optimization 1: Selective Subscriptions with Filters
Always filter subscriptions at the database level, not the client level:
// ❌ Bad: Subscribe to everything, filter in client
const channel = supabase
.channel('messages')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages'
}, (payload) => {
if (payload.new.room_id === currentRoomId) {
handleNewMessage(payload);
}
})
.subscribe();
// ✅ Good: Filter at subscription level
const channel = supabase
.channel(`room:${currentRoomId}`)
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${currentRoomId}` // Database-level filter
}, handleNewMessage)
.subscribe();
Impact:
- Reduces message processing by 90%+
- Lowers bandwidth costs
- Improves client performance
Optimization 2: Leverage Row Level Security (RLS)
RLS automatically filters real-time events—use it as your first line of defense:
-- Enable RLS on messages table
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see messages in their rooms
CREATE POLICY "Users see messages in joined rooms"
ON messages FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM room_members
WHERE room_members.room_id = messages.room_id
AND room_members.user_id = auth.uid()
)
);
Client subscription (no filter needed—RLS handles it):
// Realtime automatically applies RLS
const channel = supabase
.channel('messages')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages'
}, handleNewMessage)
.subscribe();
How it works:
- User inserts message into
messagestable - Postgres triggers NOTIFY with message payload
- Realtime server receives NOTIFY
- Realtime checks RLS policy for each subscribed user
- Only users passing RLS receive the event
Performance: RLS filtering happens in-memory on Realtime server—very efficient.
Optimization 3: Use Broadcast Channels for Ephemeral Data
For data that doesn’t need persistence (typing indicators, cursor positions, presence), use broadcast channels instead of database tables:
// ❌ Bad: Store ephemeral data in database
await supabase.from('typing_indicators').insert({
user_id: currentUser.id,
room_id: roomId,
is_typing: true
});
// Trigger cleanup after 3 seconds
setTimeout(async () => {
await supabase.from('typing_indicators').delete().eq('user_id', currentUser.id);
}, 3000);
// ✅ Good: Use broadcast channels (no database writes)
const channel = supabase.channel(`room:${roomId}`);
// Send typing indicator
channel.send({
type: 'broadcast',
event: 'typing',
payload: { user_id: currentUser.id, is_typing: true }
});
// Receive typing indicators
channel.on('broadcast', { event: 'typing' }, (payload) => {
console.log(`${payload.user_id} is typing...`);
});
Benefits:
- Zero database writes (no INSERT/UPDATE/DELETE overhead)
- No storage costs for ephemeral data
- Lower latency (no database round-trip)
- Automatic cleanup (messages disappear when clients disconnect)
Use cases:
- Typing indicators
- Cursor positions in collaborative editors
- Presence (online/offline status)
- Live reactions (👍, ❤️)
- Voice call signaling
Optimization 4: Implement Client-Side Throttling
Prevent message floods with throttling on the client:
import { throttle } from 'lodash-es';
// Throttle cursor position updates to 100ms
const sendCursorPosition = throttle((x: number, y: number) => {
channel.send({
type: 'broadcast',
event: 'cursor',
payload: { user_id: currentUser.id, x, y }
});
}, 100); // Max 10 messages/second
// Use throttled function
document.addEventListener('mousemove', (e) => {
sendCursorPosition(e.clientX, e.clientY);
});
Impact:
- Reduces message volume by 90% for high-frequency events
- Improves UX (smoother animations, less jitter)
- Lowers bandwidth costs
Optimization 5: Batch Database Changes
Reduce NOTIFY volume by batching related changes:
// ❌ Bad: 100 individual INSERTs = 100 NOTIFY events
for (const message of messages) {
await supabase.from('messages').insert(message);
}
// ✅ Good: 1 batch INSERT = 1 NOTIFY event
await supabase.from('messages').insert(messages);
Advanced: Use Postgres functions for atomic batches:
CREATE OR REPLACE FUNCTION bulk_insert_messages(messages jsonb)
RETURNS void AS $$
BEGIN
INSERT INTO messages (room_id, user_id, content)
SELECT
(value->>'room_id')::uuid,
(value->>'user_id')::uuid,
value->>'content'
FROM jsonb_array_elements(messages);
END;
$$ LANGUAGE plpgsql;
Call from client:
await supabase.rpc('bulk_insert_messages', {
messages: JSON.stringify([
{ room_id: '...', user_id: '...', content: 'Hello' },
{ room_id: '...', user_id: '...', content: 'World' }
])
});
// Generates single NOTIFY event
Optimization 6: Connection Pooling and Cleanup
Properly manage WebSocket connections to avoid leaks:
class RealtimeManager {
private channels = new Map<string, RealtimeChannel>();
subscribe(channelId: string, callback: (payload: any) => void) {
// Reuse existing channel if available
if (this.channels.has(channelId)) {
return this.channels.get(channelId)!;
}
const channel = supabase
.channel(channelId)
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${channelId}`
}, callback)
.subscribe();
this.channels.set(channelId, channel);
return channel;
}
unsubscribe(channelId: string) {
const channel = this.channels.get(channelId);
if (channel) {
supabase.removeChannel(channel);
this.channels.delete(channelId);
}
}
cleanup() {
// Unsubscribe all channels
this.channels.forEach((channel) => supabase.removeChannel(channel));
this.channels.clear();
}
}
// Use in React
useEffect(() => {
const manager = new RealtimeManager();
manager.subscribe(roomId, handleMessage);
return () => manager.cleanup(); // Cleanup on unmount
}, [roomId]);
Optimization 7: Payload Size Reduction
Minimize data sent over WebSocket:
// ❌ Bad: Send entire user object
await supabase.from('messages').insert({
room_id: roomId,
user: currentUser, // Full user object (1KB)
content: 'Hello'
});
// ✅ Good: Send only user ID (reference)
await supabase.from('messages').insert({
room_id: roomId,
user_id: currentUser.id, // Just UUID (36 bytes)
content: 'Hello'
});
// Fetch user details separately (cached)
const { data: user } = await supabase
.from('users')
.select('id, name, avatar')
.eq('id', message.user_id)
.single();
Database schema optimization:
-- Use foreign key reference instead of embedded JSON
CREATE TABLE messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
room_id uuid REFERENCES rooms(id),
user_id uuid REFERENCES users(id), -- Reference, not embedded
content text,
created_at timestamptz DEFAULT now()
);
Best Practices
1. Use Targeted Channel Names
Create specific channels for logical scopes:
// ✅ Good: Specific channel names
supabase.channel(`room:${roomId}`) // Room-specific
supabase.channel(`user:${userId}:notifications`) // User notifications
supabase.channel(`workspace:${workspaceId}`) // Workspace events
supabase.channel(`thread:${threadId}`) // Thread updates
// ❌ Bad: Generic channel names
supabase.channel('messages') // Too broad
supabase.channel('all-updates') // No filtering
2. Implement Presence Efficiently
Use built-in presence tracking with throttling:
const channel = supabase.channel(`room:${roomId}`, {
config: {
presence: {
key: currentUser.id,
},
},
});
// Track presence
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
console.log('Online users:', Object.keys(state));
});
// Join with metadata
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: currentUser.id,
name: currentUser.name,
online_at: new Date().toISOString(),
});
}
});
// Cleanup
return () => {
channel.untrack();
supabase.removeChannel(channel);
};
3. Monitor Message Volume
Track real-time metrics to identify cost spikes:
let messageCount = 0;
const channel = supabase
.channel('messages')
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'messages'
}, (payload) => {
messageCount++;
// Log metrics every 60 seconds
if (messageCount % 1000 === 0) {
console.log(`Received ${messageCount} messages in last minute`);
// Send to analytics
analytics.track('realtime_message_volume', { count: messageCount });
}
handleMessage(payload);
})
.subscribe();
4. Implement Graceful Degradation
Handle connection failures and rate limits:
const channel = supabase
.channel(`room:${roomId}`)
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}`
}, handleNewMessage)
.subscribe((status, error) => {
if (status === 'CHANNEL_ERROR') {
console.error('Realtime error:', error);
// Fall back to polling
const pollInterval = setInterval(async () => {
const { data } = await supabase
.from('messages')
.select('*')
.eq('room_id', roomId)
.order('created_at', { ascending: false })
.limit(50);
// Update UI with latest messages
updateMessages(data);
}, 5000); // Poll every 5 seconds
// Retry subscription after 30 seconds
setTimeout(() => {
clearInterval(pollInterval);
channel.subscribe();
}, 30000);
}
});
5. Use Debouncing for User Input
Prevent excessive broadcasts from typing:
import { debounce } from 'lodash-es';
// Debounce typing indicator (wait 500ms after typing stops)
const sendTypingIndicator = debounce((isTyping: boolean) => {
channel.send({
type: 'broadcast',
event: 'typing',
payload: { user_id: currentUser.id, is_typing: isTyping }
});
}, 500);
// Clear typing indicator after 3 seconds
let typingTimeout: NodeJS.Timeout;
const handleTyping = () => {
sendTypingIndicator(true);
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
sendTypingIndicator(false);
}, 3000);
};
// Attach to input
inputElement.addEventListener('input', handleTyping);
Common Pitfalls
Pitfall 1: Subscribing to Entire Tables
Problem: Broadcasting all changes to all clients wastes resources.
Mistake:
// ❌ All users receive all message updates
supabase.channel('messages').on('postgres_changes', {
event: '*',
schema: 'public',
table: 'messages'
}, handleChange);
Solution: Filter at subscription level:
// ✅ Only receive updates for current room
supabase.channel(`room:${roomId}`).on('postgres_changes', {
event: '*',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}`
}, handleChange);
Pitfall 2: Storing Ephemeral Data in Database
Problem: Unnecessary database writes for short-lived data.
Mistake:
// ❌ Writing cursor positions to database
setInterval(() => {
await supabase.from('cursor_positions').upsert({
user_id: currentUser.id,
x: cursorX,
y: cursorY
});
}, 100); // 10 writes/second per user!
Solution: Use broadcast channels:
// ✅ Broadcast cursor positions (no database)
const throttledBroadcast = throttle((x, y) => {
channel.send({
type: 'broadcast',
event: 'cursor',
payload: { user_id: currentUser.id, x, y }
});
}, 100);
document.addEventListener('mousemove', (e) => {
throttledBroadcast(e.clientX, e.clientY);
});
Pitfall 3: Not Cleaning Up Subscriptions
Problem: Memory leaks and zombie connections.
Mistake:
// ❌ Subscription never cleaned up
useEffect(() => {
const channel = supabase.channel(`room:${roomId}`)
.on('postgres_changes', { ... }, handleChange)
.subscribe();
// Missing cleanup!
}, [roomId]);
Solution: Always unsubscribe:
// ✅ Proper cleanup
useEffect(() => {
const channel = supabase.channel(`room:${roomId}`)
.on('postgres_changes', { ... }, handleChange)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [roomId]);
Pitfall 4: Ignoring RLS Performance
Problem: Complex RLS policies slow down real-time filtering.
Slow policy:
-- ❌ Subquery executed for every message
CREATE POLICY "Users see messages in rooms"
ON messages FOR SELECT
USING (
room_id IN (
SELECT room_id FROM room_members
WHERE user_id = auth.uid()
)
);
Optimized policy:
-- ✅ Use EXISTS with proper index
CREATE POLICY "Users see messages in rooms"
ON messages FOR SELECT
USING (
EXISTS (
SELECT 1 FROM room_members
WHERE room_members.room_id = messages.room_id
AND room_members.user_id = auth.uid()
)
);
-- Add index to support policy
CREATE INDEX idx_room_members_user_room
ON room_members(user_id, room_id);
Pitfall 5: Broadcasting Large Payloads
Problem: Excessive bandwidth consumption.
Mistake:
// ❌ Broadcasting entire document state (50KB+)
channel.send({
type: 'broadcast',
event: 'document_update',
payload: {
document: fullDocumentState, // 50KB JSON
timestamp: Date.now()
}
});
Solution: Send only deltas:
// ✅ Broadcast only changes (operational transforms)
channel.send({
type: 'broadcast',
event: 'document_update',
payload: {
ops: [
{ retain: 10 },
{ insert: 'Hello' },
{ delete: 3 }
],
timestamp: Date.now()
}
});
Real-World Applications
Case Study 1: Collaborative Code Editor
Scenario: Real-time code editor with 500 concurrent users editing 50 shared documents.
Initial implementation:
- Subscribe to entire
documentstable - Broadcast full document state on every keystroke
- Cost: $2,800/month (message volume)
- Latency: 200-500ms
Optimized implementation:
// 1. Subscribe to specific document
const channel = supabase
.channel(`document:${documentId}`)
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'documents',
filter: `id=eq.${documentId}`
}, handleDocumentUpdate)
.subscribe();
// 2. Use broadcast for cursor positions and selections
channel.on('broadcast', { event: 'cursor' }, handleCursor);
channel.on('broadcast', { event: 'selection' }, handleSelection);
// 3. Send operational transforms (not full document)
const sendEdit = throttle((ops) => {
channel.send({
type: 'broadcast',
event: 'edit',
payload: { user_id: currentUser.id, ops }
});
}, 50); // Max 20 messages/second
Results:
- Cost: $780/month (72% reduction)
- Latency: 50-80ms (75% improvement)
- Message volume: 2M → 500K/month
Case Study 2: Trading Dashboard
Scenario: Live stock prices for 10,000 concurrent users watching 50 popular stocks.
Challenge: Stock prices update 10 times/second (500 updates/second total).
Naive approach:
- Broadcast every price update to all users: 500 updates/sec × 10,000 users = 5M messages/sec
- Cost: Completely infeasible
Optimized approach:
// Client subscribes to specific symbols
const channel = supabase.channel(`stocks:${userId}`);
// User selects which stocks to watch
const watchlist = ['AAPL', 'GOOGL', 'MSFT'];
// Backend filters updates per user
await supabase.from('user_watchlists').upsert({
user_id: currentUser.id,
symbols: watchlist
});
// Edge function handles filtering
const stockUpdates = supabase.channel('stock-feed')
.on('broadcast', { event: 'price_update' }, async (payload) => {
// Check if user is watching this symbol
const { data: userWatchlist } = await supabase
.from('user_watchlists')
.select('symbols')
.eq('user_id', currentUser.id)
.single();
if (userWatchlist?.symbols.includes(payload.symbol)) {
// Forward to user's channel
supabase.channel(`stocks:${currentUser.id}`).send({
type: 'broadcast',
event: 'price_update',
payload
});
}
});
Results:
- Message volume: 5M → 150K messages/sec (97% reduction)
- Cost: $8,400/month → $1,100/month (87% reduction)
- Users receive only relevant updates (3-5 stocks average)
Case Study 3: Multi-Player Game
Scenario: 1,000 concurrent players in 100 game rooms (10 players per room).
Implementation:
// Efficient room-based architecture
const gameChannel = supabase.channel(`game:${roomId}`, {
config: {
presence: { key: playerId },
broadcast: { self: false } // Don't echo messages to sender
}
});
// Track player presence
gameChannel.on('presence', { event: 'sync' }, () => {
const players = gameChannel.presenceState();
updatePlayerList(players);
});
// Player actions (throttled)
const sendPlayerAction = throttle((action) => {
gameChannel.send({
type: 'broadcast',
event: 'action',
payload: { player_id: playerId, ...action }
});
}, 50); // 20 actions/second max
// Join room
gameChannel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await gameChannel.track({
player_id: playerId,
name: playerName,
position: { x: 0, y: 0 }
});
}
});
Performance:
- Latency: 30-60ms (excellent for real-time gaming)
- Message volume: ~50K messages/minute (well within limits)
- Cost: $450/month for 1,000 concurrent players
- Bandwidth: 2GB/day (broadcast channels, no database)
Conclusion
Reducing Supabase Realtime costs by 73% isn’t magic—it’s engineering discipline. The key insight is that most real-time messages are wasted, sent to clients that don’t need them or containing data that doesn’t need persistence.
The Five-Step Cost Optimization Framework:
- Filter at the source: Use RLS and subscription filters to limit message propagation
- Choose the right channel type: Database changes for persistent data, broadcast for ephemeral
- Throttle and batch: Reduce message frequency with client-side throttling and server-side batching
- Minimize payload size: Send IDs and deltas, not entire objects
- Clean up aggressively: Unsubscribe from channels when components unmount
Cost vs. Performance Trade-offs:
| Optimization | Cost Reduction | Implementation Effort | Performance Impact |
|---|---|---|---|
| Subscription filters | 60-80% | Low | Positive (less processing) |
| Broadcast channels | 40-60% | Medium | Positive (lower latency) |
| Client throttling | 30-50% | Low | Neutral |
| RLS optimization | 20-30% | Medium | Positive (faster filtering) |
| Payload reduction | 15-25% | Low | Positive (less bandwidth) |
ROI Analysis (10,000 concurrent users):
- Before optimization: $3,600/month
- After optimization: $972/month
- Savings: $2,628/month ($31,536/year)
- Implementation time: 2-3 developer days
- Break-even: Immediate
Real-time features don’t have to break the bank. By understanding how Supabase Realtime works and implementing these battle-tested optimizations, you can build collaborative, live applications that scale efficiently and cost-effectively.
Comments
Comments section will be integrated here. Popular options include Disqus, Utterances, or custom comment systems.