Supabase Realtime en la práctica: gestión de conexiones WebSocket y estrategias de reconexión

El móvil vibró.
Era un mensaje del cliente: «En vuestra app de chat, los usuarios dicen que los mensajes llegan con retraso y a veces hay que refrescar la página para ver los nuevos.»
Miré la pantalla y se me encogió el estómago. Conozco demasiado bien ese problema: el WebSocket se cortó, pero el frontend no lo sabe. El usuario sigue escribiendo y enviando, cree que el mensaje salió, y en realidad se perdió a mitad de camino.
La primera vez que usé Supabase Realtime caí en la misma trampa. Estaba haciendo un proyecto de pizarra colaborativa y pensé que suscribirse a cambios de base de datos era cuestión de unas líneas:
supabase.channel('board').on('postgres_changes', ...).subscribe()
Dos días después del lanzamiento, un compañero me dijo: «La sincronización se queda colgada a menudo; a veces desaparece a mitad una línea que estabas dibujando.»
Al investigar, el WebSocket se había cortado en silencio. Sin error, sin aviso: simplemente «muerto». Entonces entendí que la suscripción en tiempo real no es solo escribir el subscribe; gestionar la conexión es lo que marca la diferencia.
En este artículo recojo los tropiezos que tuve y las soluciones que fui probando. Me centro en el ciclo de vida de la conexión WebSocket, la parte que casi ningún tutorial explica bien. Primero cómo elegir entre las tres funciones, luego Postgres Changes paso a paso, y al final reconexión y ajustes en producción.
1. Supabase Realtime: ¿Broadcast, Presence o Postgres Changes?
Al empezar con Supabase Realtime, Broadcast, Presence y Postgres Changes me mareaban. La documentación dice que son tres capacidades distintas, pero ¿cuál usar?
En resumen, la diferencia clave es dónde viven los datos:
| Función | Almacenamiento | Escenario típico | Latencia |
|---|---|---|---|
| Broadcast | Solo memoria, sin persistencia | Mensajes entre clientes, sincronización del cursor | Mínima |
| Presence | Almacén clave-valor en memoria (CRDT) | Lista de usuarios en línea, estado colaborativo | Baja |
| Postgres Changes | Base PostgreSQL | Mensajes de chat, cambios de estado de pedidos | Media |
La tabla puede sonar abstracta. En otras palabras:
Broadcast es como un «megáfono». Dices algo y quien escucha lo oye, pero no queda rastro. Sirve para datos efímeros: en edición colaborativa, mueves el ratón y los demás ven tu cursor; a nadie le importa dónde estaba hace cinco segundos.
Presence es como un «libro de firmas». Cada uno entra, deja su estado (en línea, ausente, editando…) y todos ven la lista. El estado se sincroniza solo y usa CRDT (tipos replicados sin conflicto), así que dos personas editando a la vez no rompen nada.
Postgres Changes es un «listener de base de datos». Si cambian datos en PostgreSQL, recibes aviso. Es la opción más «pesada» y la más fiable: los datos están en la base; aunque te desconectes y vuelvas, no se pierden.
¿Cómo elegir? Un método sencillo
Hazte dos preguntas:
-
¿Los datos deben persistir?
- Sí → Postgres Changes
- No → sigue con la segunda
-
¿Es un «evento» o un «estado»?
- Evento (ocurrió algo) → Broadcast
- Estado (alguien está haciendo algo) → Presence
Ejemplo en chat: «enviar mensaje» es evento (Broadcast o Postgres Changes); «escribiendo…» es estado (Presence); «notificación de mensaje nuevo» con persistencia → Postgres Changes.
En mi pizarra colaborativa quedó así:
- Trazos del pincel → Broadcast (rápido, sin guardar)
- Quién está en línea y en qué zona → Presence
- Contenido guardado de la pizarra → Postgres Changes
2. Suscripción en tiempo real: Postgres Changes
Una vez elegido Postgres Changes, lo primero es activar la publication.
Supabase no emite cambios de todas las tablas por defecto (sería muy costoso). Debes indicar explícitamente qué tabla quieres escuchar.
-- Ejecutar en el SQL Editor de Supabase
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
Tras este comando, los INSERT, UPDATE y DELETE en messages se emiten por Realtime.
¿Cómo escribir la suscripción?
Ejemplo completo: push en tiempo real de mensajes nuevos en un chat:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'https://your-project.supabase.co',
'your-anon-key'
)
// Crear canal y suscribirse
const channel = supabase
.channel('messages-channel') // nombre del canal a tu gusto
.on(
'postgres_changes',
{
event: 'INSERT', // solo inserciones
schema: 'public',
table: 'messages'
},
(payload) => {
console.log('Nuevo mensaje:', payload.new)
// payload.new es la fila insertada
appendMessage(payload.new)
}
)
.subscribe((status) => {
console.log('Estado de suscripción:', status)
})
// Al desmontar el componente, limpia
// channel.unsubscribe()
Parece simple, pero hay detalles que pican:
Trampa 1: valores de event
event puede ser 'INSERT', 'UPDATE', 'DELETE' o '*' para todo. Si solo te importan mensajes nuevos, no uses '*': ahorras tráfico innecesario.
Trampa 2: estructura de payload
payload no es la fila entera, sino un objeto:
payload.new: datos nuevos (INSERT/UPDATE)payload.old: datos anteriores (UPDATE/DELETE; requiere replica identity)payload.eventType: tipo de eventopayload.schema,payload.table: origen
Trampa 3: Row Level Security aplica
Muchos lo pasan por alto: las suscripciones Realtime también respetan RLS.
Con RLS configurado, el usuario solo recibe cambios que puede ver. Si messages limita a conversaciones en las que participa, Realtime solo empuja esos mensajes, no todo el flujo para filtrar en el cliente.
Ventaja clara de Supabase Realtime: no duplicas la lógica de seguridad.
Obtener datos antiguos (replica identity)
Por defecto, en UPDATE y DELETE payload.old viene vacío. Si necesitas el valor anterior (p. ej. «quién cambió qué»), activa replica identity:
ALTER TABLE messages REPLICA IDENTITY FULL;
Aumenta el coste en escritura y el volumen de WAL. En producción, valora si de verdad lo necesitas.
3. Gestión de conexiones WebSocket: trampas habituales
Volvamos al problema inicial: el WebSocket se cortó y el frontend no lo sabe.
Supabase Realtime usa Phoenix Channels por debajo; los cambios de estado disparan callbacks. Tienes que escucharlos; si no, dejas de recibir mensajes.
Estados de conexión
El callback de suscripción recibe status con varios valores:
| Estado | Significado | Qué hacer |
|---|---|---|
SUBSCRIBED | Suscripción OK | Recibir mensajes con normalidad |
CHANNEL_ERROR | Error de conexión | Registrar log, intentar reconectar |
TIMED_OUT | Timeout (sin respuesta) | Posible red inestable, reconectar |
CLOSED | Conexión cerrada | Usuario o servidor cerró |
En la práctica hay otra trampa: los cambios pueden ser muy rápidos. Con un bache de red puedes ver CHANNEL_ERROR → CLOSED → SUBSCRIBED (reconexión automática) sin enterarte del fallo intermedio.
Acabé añadiendo un monitor global que registra cada transición:
const channel = supabase
.channel('messages-channel')
.on('postgres_changes', { ... }, handler)
.subscribe((status, err) => {
logConnectionStatus(status, err) // estado + timestamp
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
showReconnectingToast() // aviso al usuario
}
if (status === 'SUBSCRIBED') {
hideReconnectingToast()
syncMissedMessages() // recuperar lo perdido en la desconexión
}
})
Heartbeat: ¿cómo sabe que la conexión sigue viva?
Realtime envía heartbeats periódicos (código en keep_alive.ex); el cliente responde con confirmación.
Si el cliente no responde varias veces, el servidor cierra. Si el cliente no recibe heartbeat, dispara timeout y reconexión.
No hace falta manejar el heartbeat a mano: el SDK de Supabase lo hace. Lo que sí importa es la estrategia de reconexión tras el timeout.
heartbeatCallback: monitorizar el heartbeat (novedad 2026)
El heartbeat es automático, pero a veces la conexión parece viva y no llegan mensajes.
En abril de 2026 Supabase añadió heartbeatCallback para escuchar el estado del heartbeat:
const channel = supabase.channel('messages-channel', {
config: {
heartbeatCallback: (status) => {
console.log('Estado heartbeat:', status)
// Valores posibles:
// - 'ok': heartbeat OK
// - 'timeout': servidor sin respuesta, posible desconexión
// - 'error': heartbeat fallido
if (status === 'timeout') {
// Reconectar de forma proactiva, sin esperar al SDK
channel.unsubscribe()
setTimeout(() => channel.subscribe(), 1000)
}
}
}
})
Ventaja: detectas el problema antes que el SDK.
Por defecto el SDK puede esperar a tres heartbeats fallidos. Con heartbeatCallback actúas en el primero: en apps muy sensibles al tiempo real (colaboración en línea) reduces decenas de segundos de «conexión fantasma».
En pruebas, con heartbeatCallback el tiempo medio de detectar y recuperar una caída bajó de unos 45 s a unos 12 s.
worker: true: pestañas en segundo plano
Otro dolor: el usuario cambia de pestaña y la conexión muere en silencio.
Chrome y Firefox limitan WebSockets en pestañas en background: el heartbeat se retrasa o se pausa y el servidor cree que el cliente murió.
En mayo de 2026 Supabase añadió worker: true para mover el WebSocket a un Web Worker:
const channel = supabase.channel('messages-channel', {
config: {
worker: true // ejecutar en Web Worker
}
})
El Worker no sufre el throttling del navegador: el heartbeat sigue aunque la pestaña esté en segundo plano.
Cuándo usarlo:
- Apps colaborativas (cambios frecuentes de pestaña)
- Atención al cliente (varios chats a la vez)
- Sincronización en background (el usuario no mira la pantalla)
Nota: un Worker extra consume más memoria; en apps simples no hace falta. Donde el tiempo real importa, suele compensar.
Datos de prueba: sin worker: true, tras 5 minutos en background la tasa de heartbeat exitoso bajó del 98 % al 63 %; con worker: true, se mantuvo por encima del 96 %.
Reconexión: backoff exponencial vs reconexión inmediata
La reconexión automática por defecto usa backoff exponencial: 1 s, 2 s, 4 s… hasta ~30 s.
Protege al servidor si está saturado; el usuario puede esperar bastante.
En colaboración (pizarra, documentos) prefiero una estrategia más agresiva:
// Reconexión manual, sin depender del backoff por defecto
let reconnectAttempts = 0
const MAX_RECONNECT = 10
function handleDisconnect() {
if (reconnectAttempts >= MAX_RECONNECT) {
showFatalError('No se pudo restaurar la conexión. Recarga la página.')
return
}
// Primeros intentos rápidos, luego más lentos
const delay = reconnectAttempts < 3 ? 1000 : 3000
setTimeout(() => {
reconnectAttempts++
channel.subscribe() // reintentar suscripción
}, delay)
}
Tras reconectar: ¿qué pasa con los mensajes del corte?
Lo más incómodo: 30 s desconectado, 10 mensajes enviados por otros, ¿cómo los recuperas?
Opción 1: API desde el frontend
Tras reconectar, llama a una API con mensajes posteriores al último ID recibido:
// Guardar el último ID recibido
let lastMessageId = null
function syncMissedMessages() {
supabase
.from('messages')
.select('*')
.gt('id', lastMessageId)
.order('created_at', { ascending: true })
.then(({ data }) => {
// Añadir mensajes perdidos a la lista
appendMessages(data)
lastMessageId = data[data.length - 1]?.id
})
}
Opción 2: el servidor empuja cambios pendientes
Requiere backend: guardar «cambios no entregados» y enviarlos al reconectar. Más complejo, más fiable.
En proyectos pequeños, la opción 1 basta. Lo crucial: sincronizar en cuanto vuelvas a SUBSCRIBED, no esperar a que el usuario refresque.
4. Broadcast y Presence: más allá del chat
Los capítulos anteriores van de Postgres Changes; aquí Broadcast y Presence.
Broadcast: cursor en editor colaborativo
Ver el cursor de otros mejora mucho la experiencia. Broadcast encaja perfecto:
// Enviar posición del cursor propio
const broadcastChannel = supabase.channel('editor-cursors')
// Escuchar cursores ajenos
broadcastChannel
.on('broadcast', { event: 'cursor-move' }, (payload) => {
updateRemoteCursor(payload.userId, payload.x, payload.y)
})
.subscribe()
// Al mover el ratón, emitir
document.addEventListener('mousemove', (e) => {
broadcastChannel.send({
type: 'broadcast',
event: 'cursor-move',
payload: {
userId: currentUser.id,
x: e.clientX,
y: e.clientY
}
})
})
Detalles:
broadcastChannel.send()es envío activo, no callback de suscripción- El nombre del canal es libre; distintos editores, distintos canales
- La posición del cursor no necesita persistencia; «fire and forget» de Broadcast encaja
Presence: quién está en línea
Presence sirve para estado. Ejemplo: lista de usuarios conectados:
const presenceChannel = supabase.channel('online-users', {
config: {
presence: {
key: 'user_id' // identificar usuario único
}
}
})
presenceChannel
.on('presence', { event: 'sync' }, () => {
const state = presenceChannel.presenceState()
// state: objeto, key user_id, value array de estados
renderOnlineUsers(Object.keys(state))
})
.on('presence', { event: 'join' }, ({ newPresences }) => {
// Usuario nuevo
showToast(`${newPresences[0].user_name} se unió`)
})
.on('presence', { event: 'leave' }, ({ leftPresences }) => {
// Usuario salió
showToast(`${leftPresences[0].user_name} se fue`)
})
.subscribe()
// Al conectar, registrar tu estado
presenceChannel.track({
user_id: currentUser.id,
user_name: currentUser.name,
online_at: new Date().toISOString()
})
track() dice «estoy aquí». El estado se replica a todos los suscriptores con CRDT, sin conflictos raros.
Canales privados: quién puede suscribirse
Por defecto, quien tenga la anon key puede entrar en canales públicos. A veces necesitas restringir acceso, p. ej. espacio de equipo privado.
Supabase permite controlar canales con políticas RLS:
-- Política en el schema realtime
CREATE POLICY "Only team members can join private channel"
ON realtime.channels
FOR ALL
USING (
-- Comprobar que el usuario pertenece al equipo
EXISTS (
SELECT 1 FROM team_members
WHERE team_id = channel.team_id
AND user_id = auth.uid()
)
);
Solo miembros del equipo entran en private-team-xxx; el resto recibe rechazo.
5. Producción: parámetros que debes conocer
En local todo va fino; en producción aparecen sorpresas. Muchas vienen de la configuración.
Parámetros clave del servicio Realtime
La configuración por defecto vale para la mayoría; en alta concurrencia conviene afinar:
| Parámetro | Por defecto | Recomendación | Función |
|---|---|---|---|
DB_POOL_SIZE | 10 | Según conexiones concurrentes | Tamaño del pool PostgreSQL |
DB_QUEUE_TARGET | 100 ms | Más bajo = menos latencia, más CPU | Espera antes de envío por lotes |
SUBSCRIBER_LIMIT | 200 | Según usuarios | Suscriptores máximos por canal |
Si la latencia sube, prueba bajar DB_QUEUE_TARGET (p. ej. 50 ms). El servidor consulta cambios más a menudo y sube el uso de CPU.
Límites de conexión en arquitectura multi-tenant
Trampa frecuente: un canal por tenant y el número de canales explota.
Supabase limita suscripciones totales por proyecto (plan Pro: 5000 concurrentes). Con 1000 tenants y ~5 usuarios en línea por tenant, rozas el límite.
Soluciones:
- Unificar canales: un canal con
filterpor tenant - Suscripción selectiva: solo el tenant activo, no todos
// filter: solo mensajes del tenant actual
supabase
.channel('tenant-messages')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: 'tenant_id=eq.123' // solo tenant 123
},
handler
)
.subscribe()
Comparativa: Supabase vs Pusher vs Firebase
Resumen rápido de opciones habituales:
| Solución | Coste | Funciones | Curva de aprendizaje |
|---|---|---|---|
| Supabase Realtime | Gratis (Pro 25 $/mes) | Alta (tres en uno + base de datos) | Media |
| Pusher | Desde 29 $ | Media (WebSocket puro) | Baja |
| Firebase Realtime DB | Por uso | Media (ecosistema Firebase) | Baja |
Ventajas de Supabase: Postgres Changes escucha la base sin lógica extra de push; RLS unificado. Desventaja: hay que entender PostgreSQL; la curva es un poco más empinada.
Si ya usas Supabase para Auth y Storage, sumar Realtime es natural. Si solo quieres WebSocket simple, Pusher puede ser más rápido de arrancar.
Resumen
En tres ideas:
Elige bien la función: Broadcast para eventos, Presence para estado, Postgres Changes para persistencia. ¿Persistencia? ¿Evento o estado? Con eso decides.
Gestiona la conexión: suscrito no significa recibir siempre. Escucha estados, avisa «reconectando», sincroniza al volver. Ahí se gana estabilidad.
Ajusta en producción: no es «local pero más grande». DB_POOL_SIZE, DB_QUEUE_TARGET y compañía afectan latencia y throughput. Antes de subir, mira los valores por defecto.
Mi error inicial — WebSocket muerto sin enterarme — lo arreglé con monitor de estado + aviso de reconexión. La experiencia mejoró al momento: sin red ves «restaurando conexión»; al volver, mensajes al día sin refrescar.
Si aún no has probado Supabase Realtime, empieza por Postgres Changes: lo más simple y lo más usado. Con la serie de Auth (verificación de email, OAuth) montas un backend en tiempo real completo.
Dudas en comentarios o en la documentación oficial. La parte de arquitectura está clara; para Phoenix Channels y el adaptador PG2, el código fuente también ayuda.
FAQ
¿En qué se diferencian las tres funciones de Supabase Realtime?
¿Cómo recuperarse tras una desconexión WebSocket?
• Primeros intentos rápidos (1 s)
• Luego más lentos (3 s)
• Tras reconectar, sincroniza de inmediato los mensajes perdidos
¿Las suscripciones Realtime respetan las reglas RLS?
¿Qué parámetros de configuración importan en producción?
• DB_POOL_SIZE: tamaño del pool de conexiones PostgreSQL, por defecto 10
• DB_QUEUE_TARGET: tiempo de espera para envío por lotes, por defecto 100 ms
• SUBSCRIBER_LIMIT: suscriptores máximos por canal, por defecto 200
¿Cómo evitar la explosión de canales en sistemas multi-tenant?
¿Cómo se compara Supabase Realtime con Pusher o Firebase?
12 min de lectura · Publicado el: 12 may 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 Realtime en la práctica: comparación de tres modos y desarrollo de apps colaborativas
Supabase Realtime ofrece tres modos en tiempo real: Postgres Changes, Presence y Broadcast. Este artículo compara cada modo y ofrece ejemplos completos de apps colaborativas con configuración RLS.
Parte 5 de 10
Siguiente
Supabase Storage en la práctica: subida de archivos, CDN y control de acceso
Guía completa de Supabase Storage: comparación de tres modos de acceso, subida TUS por fragmentos, optimización Smart CDN y análisis de costos frente a R2/S3, con ejemplos React y solución de problemas.
Parte 7 de 10



Comentarios
Inicia sesión con GitHub para dejar un comentario