Realtime: Apps That Update Themselves
20 lines. That's what the real-time chat cost. No custom WebSockets, no Redis, no middleware server to babysit at 3 AM.
When I saw it working, I finally got why people can't stop talking about Supabase Realtime. Here you'll build features that update themselves: from listening to database changes to knowing who's online.
What is Supabase Realtime
Supabase Realtime is a service that allows receiving real-time updates from your database. It uses WebSockets under the hood and offers three main functionalities:
- Database Changes: Listen to table changes
- Broadcast: Messages between clients without going through DB
- Presence: Online/offline user state
Listen to database changes
Subscribe to a table
// src/realtime/tasks.ts
import { supabase } from '../lib/supabase'
// Listen to new inserts in tasks table
const channel = supabase
.channel('tasks-changes')
.on(
'postgres_changes',
{
event: '*', // INSERT, UPDATE, DELETE, or '*'
schema: 'public',
table: 'tasks'
},
(payload) => {
console.log('Change received!', payload)
// payload.new - new values
// payload.old - previous values
// payload.eventType - 'INSERT', 'UPDATE', 'DELETE'
}
)
.subscribe()
// To disconnect
// supabase.removeChannel(channel)
Filter specific changes
// src/realtime/filtered.ts
const channel = supabase
.channel('my-tasks')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'tasks',
filter: 'user_id=eq.123e4567-e89b-12d3-a456-426614174000'
},
(payload) => {
console.log('New task for user:', payload.new)
}
)
.subscribe()
Multiple subscriptions in one channel
// src/realtime/multiple.ts
const channel = supabase
.channel('db-changes')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'tasks' },
(payload) => console.log('New task:', payload.new)
)
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'tasks' },
(payload) => console.log('Task updated:', payload.new)
)
.on(
'postgres_changes',
{ event: 'DELETE', schema: 'public', table: 'tasks' },
(payload) => console.log('Task deleted:', payload.old)
)
.subscribe()
Broadcast
Broadcast allows sending messages between clients without saving to the database. Ideal for chat, notifications, and temporary actions.
Send messages
// src/realtime/broadcast.ts
const channel = supabase.channel('room-1')
// Send message
await channel.send({
type: 'broadcast',
event: 'chat-message',
payload: {
user: 'Kevin',
message: 'Hello!',
timestamp: new Date().toISOString()
}
})
Receive messages
// src/realtime/subscribe.ts
const channel = supabase
.channel('room-1')
.on(
'broadcast',
{ event: 'chat-message' },
(payload) => {
console.log('Message received:', payload.payload)
// { user: 'Kevin', message: 'Hello!', timestamp: '...' }
}
)
.subscribe()
Example: Real-time chat
// src/chat.ts
import { supabase } from './lib/supabase'
export class RealtimeChat {
private channel: ReturnType<typeof supabase.channel>
constructor(roomId: string) {
this.channel = supabase.channel(`chat-${roomId}`)
}
join(userId: string, onMessage: (msg: any) => void) {
this.channel
.on('broadcast', { event: 'message' }, ({ payload }) => {
onMessage(payload)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
console.log('Joined room')
}
})
}
async sendMessage(userId: string, text: string) {
await this.channel.send({
type: 'broadcast',
event: 'message',
payload: {
userId,
text,
timestamp: new Date().toISOString()
}
})
}
leave() {
supabase.removeChannel(this.channel)
}
}
// Usage
const chat = new RealtimeChat('general')
chat.join('user-123', (msg) => {
console.log(`${msg.userId}: ${msg.text}`)
})
await chat.sendMessage('user-123', 'Hello everyone!')
Presence
Presence allows knowing who is online in a room. Ideal for "active users" indicators, "who's viewing this", etc.
Track presence
// src/realtime/presence.ts
const channel = supabase.channel('online-users')
// Sync presence
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
console.log('Online users:', Object.keys(state).length)
console.log('Users:', state)
})
.on('presence', { event: 'join' }, ({ key, newPresences }) => {
console.log('User joined:', key, newPresences)
})
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
console.log('User left:', key, leftPresences)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: 'user-123',
username: 'kevin',
online_at: new Date().toISOString()
})
}
})
Example: Online users indicator
// src/components/OnlineUsers.ts
import { supabase } from '../lib/supabase'
export class OnlineUsersIndicator {
private channel: ReturnType<typeof supabase.channel>
private container: HTMLElement
constructor(containerId: string) {
this.container = document.getElementById(containerId)!
this.channel = supabase.channel('page-presence')
}
start(userId: string) {
this.channel
.on('presence', { event: 'sync' }, () => {
const state = this.channel.presenceState()
const count = Object.keys(state).length
this.container.textContent = `${count} user${count !== 1 ? 's' : ''} online`
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await this.channel.track({
user_id: userId,
page: window.location.pathname
})
}
})
}
stop() {
supabase.removeChannel(this.channel)
}
}
Complete example: Real-time dashboard
// src/dashboard.ts
import { supabase } from './lib/supabase'
export class RealtimeDashboard {
private channels: ReturnType<typeof supabase.channel>[] = []
start() {
// Listen to new orders
const ordersChannel = supabase
.channel('orders')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'orders' },
(payload) => {
this.addOrderToUI(payload.new)
this.updateStats()
}
)
.subscribe()
// Listen to inventory updates
const inventoryChannel = supabase
.channel('inventory')
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'products' },
(payload) => {
this.updateProductInUI(payload.new)
}
)
.subscribe()
this.channels.push(ordersChannel, inventoryChannel)
}
private addOrderToUI(order: any) {
// Add order to UI
console.log('New order:', order)
}
private updateProductInUI(product: any) {
// Update product in UI
console.log('Product updated:', product)
}
private updateStats() {
// Update stats
console.log('Updating stats...')
}
stop() {
this.channels.forEach(ch => supabase.removeChannel(ch))
}
}
Realtime configuration
In the Supabase dashboard, go to Database > Replication to enable Realtime on specific tables.
Enable from SQL
-- Enable Realtime on a table
ALTER PUBLICATION supabase_realtime ADD TABLE tasks;
Enable for multiple tables
ALTER PUBLICATION supabase_realtime ADD TABLE tasks, orders, messages;
Realtime limits
- Concurrent connections: 200 per project (free tier)
- Messages: Unlimited
- Channels: Unlimited
- Presence: 1000 keys per channel
Common errors
"Channel not subscribing"
- Verify the table has Realtime enabled
- Check RLS policies
No events received
- Table is not in Realtime publication
- RLS policies are blocking changes
Connection disconnects
- Supabase reconnects automatically
- Check your internet connection
What's next
In the next article we cover Edge Functions: how to execute serverless logic at the edge. We'll see how to use them for webhooks, payment processing, and more.