Realtime: Apps That Update Themselves

Kevin Dávila
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.