Supabase Realtime en la práctica: comparación de tres modos y desarrollo de apps colaborativas

Mirando el indicador de «escribiendo…» en pantalla, refresqué la página del chat por decimoséptima vez. Mi amigo del otro lado estaba claramente en línea, pero los mensajes no aparecían. Construir una app verdaderamente «en tiempo real» es más difícil de lo que parece.
He tropezado con bastantes problemas de WebSocket: reconectar al cortarse, sincronizar estado, diseñar el broadcast. Hasta que el año pasado probé Supabase Realtime y descubrí que todo eso puede delegarse. Supabase ofrece tres modos en tiempo real: Postgres Changes escucha cambios en la base de datos, Presence rastrea el estado de los usuarios y Broadcast envía mensajes temporales. Cada modo encaja en escenarios distintos; usarlos bien multiplica el resultado, usarlos mal te complica la vida.
En este artículo desglosamos los tres modos: cuándo usar cada uno, cómo escribir el código y cómo configurar las políticas RLS. Al final los combinamos en una app de chat colaborativa completa.
Comparación de las tres funciones principales de Supabase Realtime
Empecemos por la conclusión: cada modo tiene su trabajo; no los mezcles sin criterio.
Postgres Changes escucha cambios en la base de datos. Ideal para mensajes de chat, notificaciones y estado de pedidos: datos que deben persistir. Los datos van a la base de datos y el cliente solo se suscribe a los cambios.
Presence rastrea el estado en línea de los usuarios. Perfecto para mostrar «quién está conectado», indicadores de escritura y posición del cursor en edición colaborativa. Los datos no van a la base de datos; viven en memoria y desaparecen al desconectarse.
Broadcast envía mensajes temporales. Ideal para movimiento del cursor en un lienzo, posiciones en tiempo real en juegos y sincronización temporal de acciones. La diferencia con Presence: Broadcast es para envíos de alta frecuencia; Presence, para sincronizar estado.
Una tabla para resumirlo:
| Modo | Dónde se guardan los datos | Casos típicos | Persistencia |
|---|---|---|---|
| Postgres Changes | PostgreSQL | Mensajes de chat, notificaciones, estado de pedidos | Sí |
| Presence | Memoria (servicio Realtime) | Usuarios en línea, indicadores de escritura | No |
| Broadcast | No se almacena (reenvío instantáneo) | Movimiento del cursor, posiciones en tiempo real | No |
Quizá pienses: ¿por qué no usar Postgres Changes para todo? La verdad es que yo también lo pensé al principio. Luego, en una pizarra colaborativa, cada movimiento del cursor iba a la base de datos y la CPU se disparó al 90%. Ahí entendí que algunos datos no necesitan persistirse.
Para elegir el modo correcto, recuerda este principio: Postgres Changes si necesitas historial, Presence si solo te importa el estado actual, Broadcast si es alta frecuencia y temporal.
Postgres Changes: escuchar cambios en la base de datos
Esta sección cubre el escenario más habitual: escuchar cambios en la base de datos. Nuevos mensajes en una sala, cambios de estado de pedidos, likes: todo eso requiere datos persistentes.
Supabase Realtime implementa la escucha mediante logical replication de PostgreSQL. En resumen: en cada INSERT, UPDATE o DELETE, el servicio Realtime captura el cambio y lo envía a los clientes suscritos.
Activar la escucha Realtime
Primero hay que habilitar la publication en la base de datos. En el SQL Editor de Supabase ejecuta:
-- Enable Realtime publication
ALTER publication supabase_realtime ADD TABLE messages;
-- For tables that need UPDATE and DELETE monitoring, must set REPLICA IDENTITY FULL
ALTER TABLE messages REPLICA IDENTITY FULL;
¿Por qué REPLICA IDENTITY FULL? Por defecto PostgreSQL solo registra la clave primaria de la fila modificada. Si necesitas los datos completos antes y después del cambio (por ejemplo, para auditoría), debes activar esta opción. Ojo: aumenta el volumen de escritura; úsala solo en tablas que lo requieran.
Código de suscripción en el cliente
El código del cliente, con mensajes de chat como ejemplo:
import { createClient } from '@supabase/supabase-js'
import { useEffect, useState } from 'react'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
interface Message {
id: string
content: string
user_id: string
created_at: string
}
export function useRealtimeMessages(roomId: string) {
const [messages, setMessages] = useState<Message[]>([])
useEffect(() => {
// First fetch historical messages
const fetchMessages = async () => {
const { data } = await supabase
.from('messages')
.select('*')
.eq('room_id', roomId)
.order('created_at', { ascending: true })
if (data) setMessages(data)
}
fetchMessages()
// Subscribe to new messages
const channel = supabase
.channel(`messages:${roomId}`)
.on(
'postgres_changes',
{
event: 'INSERT', // Only listen for inserts
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}` // Filter for specific room
},
(payload) => {
// payload.new contains the newly inserted data
setMessages(prev => [...prev, payload.new as Message])
}
)
.subscribe()
// Cleanup subscription
return () => {
supabase.removeChannel(channel)
}
}, [roomId])
return messages
}
Algunos detalles importantes:
- Primero el historial, luego el incremento: al entrar en una sala hay que mostrar mensajes anteriores y después recibir los nuevos. Es un error común suscribirse sin cargar historial.
- Parámetro filter: filtra por campo en la base de datos para evitar mensajes irrelevantes. Sintaxis:
nombre_campo=eq.valor. - Limpiar la suscripción: al desmontar el componente, llama a
removeChannelo tendrás fugas de memoria.
Configuración de seguridad RLS (¡importante!)
Muchos lo pasan por alto, pero es clave en producción. Por defecto, Realtime respeta las políticas RLS (Row Level Security) de la tabla. Sin RLS, el cliente puede no recibir nada o recibir datos que no debería ver.
Ejemplo con una tabla de mensajes:
-- Messages table
CREATE TABLE messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
room_id uuid REFERENCES rooms(id),
user_id uuid REFERENCES auth.users(id),
content text NOT NULL,
created_at timestamptz DEFAULT now()
);
-- Enable RLS
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- Allow viewing messages in own rooms
CREATE POLICY "Users can view messages in their rooms"
ON messages FOR SELECT
USING (
room_id IN (
SELECT room_id FROM room_members
WHERE user_id = auth.uid()
)
);
-- Allow room members to send messages
CREATE POLICY "Room members can insert messages"
ON messages FOR INSERT
WITH CHECK (
room_id IN (
SELECT room_id FROM room_members
WHERE user_id = auth.uid()
)
);
Las suscripciones Realtime aplican estas políticas automáticamente. El usuario solo recibe cambios de mensajes que puede ver. No intentes filtrar en el frontend: no es seguro.
Si la suscripción no recibe datos, revisa primero:
- Si la tabla está en la publication
supabase_realtime - Si las políticas RLS están bien configuradas
Presence: rastrear el estado en línea de los usuarios
Presence encaja en escenarios de «quién está conectado ahora»: contador en una sala de chat, quién edita qué en un documento, quién está escribiendo. No hace falta guardarlo en la base de datos; en memoria basta.
Principios básicos
Cada cliente, al unirse a un channel, llama a track() para registrar su estado. El servicio Realtime mantiene una instantánea de todos los clientes; cuando alguien entra, sale o actualiza su estado, todos los suscriptores reciben la notificación.
Implementar la lista de usuarios en línea
Código de un componente que muestra usuarios conectados en una sala:
import { createClient } from '@supabase/supabase-js'
import { useEffect, useState } from 'react'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
interface UserPresence {
user_id: string
username: string
online_at: string
}
export function useOnlineUsers(roomId: string, currentUser: { id: string; username: string }) {
const [users, setUsers] = useState<UserPresence[]>([])
useEffect(() => {
const channel = supabase.channel(`room:${roomId}`, {
config: {
presence: {
key: currentUser.id // Use user ID as key
}
}
})
channel
.on('presence', { event: 'sync' }, () => {
// Get all online users on sync
const state = channel.presenceState()
// presenceState() returns { [key]: [UserPresence, ...] }
const onlineUsers = Object.values(state).flat() as UserPresence[]
setUsers(onlineUsers)
})
.on('presence', { event: 'join' }, ({ newPresences }) => {
// New user joined
console.log('User joined:', newPresences)
})
.on('presence', { event: 'leave' }, ({ leftPresences }) => {
// User left
console.log('User left:', leftPresences)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
// After successful subscription, register own status
await channel.track({
user_id: currentUser.id,
username: currentUser.username,
online_at: new Date().toISOString()
})
}
})
return () => {
supabase.removeChannel(channel)
}
}, [roomId, currentUser])
return users
}
Uso sencillo:
function ChatRoom({ roomId, currentUser }) {
const onlineUsers = useOnlineUsers(roomId, currentUser)
return (
<div className="flex items-center gap-2 mb-4">
<span className="text-sm text-gray-500">
{onlineUsers.length} en línea
</span>
<div className="flex -space-x-2">
{onlineUsers.map(user => (
<div
key={user.user_id}
className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-sm"
title={user.username}
>
{user.username[0]}
</div>
))}
</div>
</div>
)
}
Indicador de escritura
Presence también sirve para el aviso de «escribiendo». La idea: al empezar a escribir, actualizas tu estado; tras unos segundos sin escribir, lo limpias.
export function useTypingIndicator(roomId: string, currentUser: { id: string }) {
const [typingUsers, setTypingUsers] = useState<string[]>([])
const channelRef = useRef<RealtimeChannel | null>(null)
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
const channel = supabase.channel(`room:${roomId}`)
channelRef.current = channel
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
const typing = Object.values(state)
.flat()
.filter((u: any) => u.is_typing && u.user_id !== currentUser.id)
.map((u: any) => u.username)
setTypingUsers(typing)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: currentUser.id,
is_typing: false
})
}
})
return () => {
supabase.removeChannel(channel)
}
}, [roomId, currentUser])
// Call when user starts typing
const setTyping = (isTyping: boolean) => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current)
}
channelRef.current?.track({
user_id: currentUser.id,
is_typing
})
// Auto-clear typing status after 3 seconds
if (isTyping) {
typingTimeoutRef.current = setTimeout(() => {
channelRef.current?.track({
user_id: currentUser.id,
is_typing: false
})
}, 3000)
}
}
return { typingUsers, setTyping }
}
Trampa habitual: no llames a track() en cada tecla; es demasiado frecuente. Usa debounce o, como arriba, limpia el estado unos segundos después de dejar de escribir.
Broadcast: seguimiento de cursor y mensajes instantáneos
Broadcast es el modo más «ligero»: no almacena ni persiste; envías y olvidas. Ideal para datos temporales de alta frecuencia.
Ejemplo de seguimiento de cursor
Pizarras colaborativas y editores multiusuario necesitan mostrar el cursor de cada persona en tiempo real. Broadcast encaja perfecto: la posición del cursor no va a la base de datos ni hace falta un historial de cursores.
import { createClient } from '@supabase/supabase-js'
import { useEffect, useState, useRef } from 'react'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
interface CursorPosition {
user_id: string
username: string
x: number
y: number
color: string
}
export function useCursors(canvasId: string, currentUser: { id: string; username: string }) {
const [cursors, setCursors] = useState<Record<string, CursorPosition>>({})
const channelRef = useRef<RealtimeChannel | null>(null)
useEffect(() => {
const channel = supabase.channel(`canvas:${canvasId}`)
channelRef.current = channel
channel
.on('broadcast', { event: 'cursor' }, ({ payload }) => {
// Received other user's cursor position
if (payload.user_id !== currentUser.id) {
setCursors(prev => ({
...prev,
[payload.user_id]: payload
}))
}
})
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [canvasId, currentUser])
// Send own cursor position
const sendCursor = (x: number, y: number) => {
channelRef.current?.send({
type: 'broadcast',
event: 'cursor',
payload: {
user_id: currentUser.id,
username: currentUser.username,
x,
y,
color: getUserColor(currentUser.id)
}
})
}
return { cursors, sendCursor }
}
// Generate color based on user ID
function getUserColor(userId: string): string {
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4']
const index = userId.charCodeAt(0) % colors.length
return colors[index]
}
En el componente:
function CollaborativeCanvas({ canvasId, currentUser }) {
const { cursors, sendCursor } = useCursors(canvasId, currentUser)
const handleMouseMove = (e: React.MouseEvent) => {
sendCursor(e.clientX, e.clientY)
}
return (
<div className="relative w-full h-full" onMouseMove={handleMouseMove}>
{/* Display other users' cursors */}
{Object.entries(cursors).map(([userId, cursor]) => (
<div
key={userId}
className="absolute pointer-events-none"
style={{ left: cursor.x, top: cursor.y }}
>
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: cursor.color }} />
<span className="text-xs ml-1">{cursor.username}</span>
</div>
))}
</div>
)
}
Broadcast vs Presence
Broadcast y Presence se solapan en parte. La diferencia:
- Broadcast: envíos de alta frecuencia (decenas por segundo), como movimiento del cursor o posiciones en juegos
- Presence: sincronización de estado (actualizaciones ocasionales), como «quién está en línea» o «escribiendo»
Funcionan mejor juntos: Broadcast para datos que «se mueven», Presence para datos de «estado».
Caso práctico completo: app de chat colaborativa
Combinemos los tres modos en una app de chat real. Funciones:
- Mensajes en tiempo real (Postgres Changes)
- Lista de usuarios en línea (Presence)
- Indicador de escritura (Presence)
Preparación de la base de datos
Primero, las tablas:
-- Rooms table
CREATE TABLE rooms (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_at timestamptz DEFAULT now()
);
-- Room members table
CREATE TABLE room_members (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
room_id uuid REFERENCES rooms(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id),
joined_at timestamptz DEFAULT now(),
UNIQUE(room_id, user_id)
);
-- Messages table
CREATE TABLE messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
room_id uuid REFERENCES rooms(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id),
content text NOT NULL,
created_at timestamptz DEFAULT now()
);
-- Enable Realtime
ALTER publication supabase_realtime ADD TABLE messages;
-- Enable RLS
ALTER TABLE messages ENABLE ROW LEVEL Security;
-- RLS policy: only view messages in own rooms
CREATE POLICY "Users can view messages in their rooms"
ON messages FOR SELECT
USING (
room_id IN (
SELECT room_id FROM room_members WHERE user_id = auth.uid()
)
);
-- RLS policy: only room members can send messages
CREATE POLICY "Room members can send messages"
ON messages FOR INSERT
WITH CHECK (
room_id IN (
SELECT room_id FROM room_members WHERE user_id = auth.uid()
)
);
Componente React completo
Versión simplificada del chat colaborativo con los tres modos:
import { createClient } from '@supabase/supabase-js'
import { useEffect, useState, useRef, useCallback } from 'react'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
interface Message {
id: string
content: string
user_id: string
username: string
created_at: string
}
interface UserPresence {
user_id: string
username: string
is_typing?: boolean
}
export function CollaborativeChat({ roomId, currentUser }) {
const [messages, setMessages] = useState<Message[]>([])
const [onlineUsers, setOnlineUsers] = useState<UserPresence[]>([])
const [typingUsers, setTypingUsers] = useState<string[]>([])
const [inputValue, setInputValue] = useState('')
const channelRef = useRef<RealtimeChannel | null>(null)
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
// Create one channel, shared by all three features
const channel = supabase.channel(`room:${roomId}`, {
config: { presence: { key: currentUser.id } }
})
channelRef.current = channel
// 1. Postgres Changes: listen for new messages
channel.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}`
},
(payload) => setMessages(prev => [...prev, payload.new as Message])
)
// 2. Presence: listen for online users and typing status
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
const users = Object.values(state).flat() as UserPresence[]
setOnlineUsers(users)
const typing = users
.filter(u => u.is_typing && u.user_id !== currentUser.id)
.map(u => u.username)
setTypingUsers(typing)
})
// Subscribe and register Presence
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: currentUser.id,
username: currentUser.username,
is_typing: false
})
// Load historical messages
const { data } = await supabase
.from('messages')
.select('*')
.eq('room_id', roomId)
.order('created_at', { ascending: true })
if (data) setMessages(data)
}
})
return () => supabase.removeChannel(channel)
}, [roomId, currentUser])
const sendMessage = async () => {
if (!inputValue.trim()) return
await supabase.from('messages').insert({
room_id: roomId,
user_id: currentUser.id,
content: inputValue.trim()
})
setInputValue('')
setTyping(false)
}
const setTyping = useCallback((isTyping: boolean) => {
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
channelRef.current?.track({
user_id: currentUser.id,
username: currentUser.username,
is_typing
})
if (isTyping) {
typingTimeoutRef.current = setTimeout(() => setTyping(false), 3000)
}
}, [currentUser])
return (
<div className="flex h-full">
{/* Left: Online users */}
<div className="w-64 border-r p-4">
<h3 className="font-bold mb-2">En línea ({onlineUsers.length})</h3>
{onlineUsers.map(user => (
<div key={user.user_id} className="py-1">
{user.username}
{user.is_typing && <span className="text-sm text-gray-500"> (escribiendo)</span>}
</div>
))}
</div>
{/* Right: Chat */}
<div className="flex-1 flex flex-col">
<div className="flex-1 overflow-y-auto p-4">
{messages.map(msg => (
<div key={msg.id} className="mb-2">
<span className="font-bold">{msg.username}: </span>
{msg.content}
</div>
))}
{typingUsers.length > 0 && (
<div className="text-gray-500 text-sm">
{typingUsers.join(', ')} escribiendo...
</div>
)}
</div>
<div className="p-4 border-t">
<input
value={inputValue}
onChange={e => { setInputValue(e.target.value); setTyping(true) }}
onKeyDown={e => e.key === 'Enter' && sendMessage()}
placeholder="Escribe un mensaje..."
className="w-full p-2 border rounded"
/>
</div>
</div>
</div>
)
}
Puntos clave de diseño
- Un solo Channel: las tres funciones comparten el mismo channel y reducen conexiones.
- Estructura de Presence: incluye
is_typingen el mismo objeto presence. - Limpieza: al desmontar el componente, llama siempre a
removeChannel.
Buenas prácticas de rendimiento y seguridad
El código ya funciona, pero antes de producción quedan detalles.
Gestión de conexiones
Cada channel consume una conexión WebSocket. Supabase permite varios channels en una conexión, pero abusar sigue siendo problemático.
Recomendaciones:
- Máximo 2-3 channels por página
- Funciones relacionadas en un solo channel (como el chat de arriba)
- Al salir de la página,
removeChannelde inmediato
// Cleanup example
useEffect(() => {
const channel = supabase.channel('my-channel')
channel.subscribe()
return () => {
// Don't just unsubscribe, use removeChannel
supabase.removeChannel(channel)
}
}, [])
RLS es obligatorio
No basta con «filtrar en el frontend». Las suscripciones Realtime aplican RLS; el usuario solo recibe cambios que puede ver.
Si no llegan datos, revisa en este orden:
- ¿La tabla está en la publication
supabase_realtime? - ¿Las políticas RLS son correctas? (usa la herramienta de prueba RLS del Dashboard de Supabase)
- ¿El usuario ha iniciado sesión? (¿qué devuelve
auth.uid()?)
Precios
Supabase Realtime factura por conexiones concurrentes: $10 por cada 1.000 conexiones pico. Para la mayoría de apps pequeñas y medianas, el plan gratuito basta. Con muchos usuarios simultáneos, controla el número de channels.
Hay un demo oficial en Multiplayer.dev para ver los tres modos en acción.
Conclusión
Elegir el modo es sencillo:
| ¿Necesitas historial? | ¿Alta frecuencia? | Modo recomendado |
|---|---|---|
| Sí | No | Postgres Changes |
| No | No | Presence |
| No | Sí | Broadcast |
Si construyes apps colaborativas (salas de chat, pizarras, edición de documentos), probablemente usarás los tres: Postgres Changes para mensajes, Presence para estado en línea, Broadcast para cursores.
Prueba primero Multiplayer.dev y luego monta un demo pequeño de chat: es la forma más rápida de aprender.
En el próximo artículo hablaré de Supabase Storage: subida de archivos y procesamiento de imágenes. Si tienes dudas sobre Realtime, déjalas en los comentarios.
FAQ
¿Cuál es la diferencia entre los tres modos de Supabase Realtime?
• Postgres Changes escucha cambios en la base de datos; los datos se persisten; ideal para mensajes de chat y estado de pedidos
• Presence rastrea el estado en línea de los usuarios; los datos están en memoria; ideal para listas de conectados e indicadores de escritura
• Broadcast envía mensajes temporales sin almacenarlos; ideal para seguimiento de cursor y posiciones en tiempo real
¿Por qué mi suscripción no recibe datos?
1. ¿La tabla está añadida a la publication supabase_realtime? (ejecuta ALTER publication supabase_realtime ADD TABLE nombre_tabla)
2. ¿Las políticas RLS están bien configuradas? (las suscripciones Realtime aplican RLS automáticamente)
3. ¿El usuario tiene permiso para ver los datos? (comprueba el valor devuelto por auth.uid())
¿Debo elegir Postgres Changes o Broadcast?
¿Los datos de Presence se persisten?
¿Cómo afecta la suscripción Realtime a RLS?
¿Cuántos channels puedo usar en una página?
14 min de lectura · Publicado el: 15 abr 2026 · Actualizado el: 21 ago 2026
Supabase en práctica
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Supabase Storage en la práctica: subida, permisos y aceleración CDN
Flujo completo de Supabase Storage: subida de archivos, configuración de permisos e integración CDN. Cubre RLS policy, aislamiento por usuario, Smart CDN y transformación de imágenes.
Parte 4 de 10
Siguiente
Supabase Realtime en la práctica: gestión de conexiones WebSocket y estrategias de reconexión
Guía práctica de Supabase Realtime: gestión de conexiones WebSocket, estrategias de reconexión y suscripciones en tiempo real con Postgres Changes. Broadcast, Presence y Postgres Changes: cuándo usar cada uno y buenas prácticas en producción
Parte 6 de 10



Comentarios
Inicia sesión con GitHub para dejar un comentario