Cambiar tema

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

Easton editorial illustration: large WebSocket plug, reconnect loop

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ónAlmacenamientoEscenario típicoLatencia
BroadcastSolo memoria, sin persistenciaMensajes entre clientes, sincronización del cursorMínima
PresenceAlmacén clave-valor en memoria (CRDT)Lista de usuarios en línea, estado colaborativoBaja
Postgres ChangesBase PostgreSQLMensajes de chat, cambios de estado de pedidosMedia

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:

  1. ¿Los datos deben persistir?

    • Sí → Postgres Changes
    • No → sigue con la segunda
  2. ¿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 evento
  • payload.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:

EstadoSignificadoQué hacer
SUBSCRIBEDSuscripción OKRecibir mensajes con normalidad
CHANNEL_ERRORError de conexiónRegistrar log, intentar reconectar
TIMED_OUTTimeout (sin respuesta)Posible red inestable, reconectar
CLOSEDConexión cerradaUsuario 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ámetroPor defectoRecomendaciónFunción
DB_POOL_SIZE10Según conexiones concurrentesTamaño del pool PostgreSQL
DB_QUEUE_TARGET100 msMás bajo = menos latencia, más CPUEspera antes de envío por lotes
SUBSCRIBER_LIMIT200Según usuariosSuscriptores 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 filter por 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ónCosteFuncionesCurva de aprendizaje
Supabase RealtimeGratis (Pro 25 $/mes)Alta (tres en uno + base de datos)Media
PusherDesde 29 $Media (WebSocket puro)Baja
Firebase Realtime DBPor usoMedia (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?
Broadcast transmite eventos entre clientes (p. ej. sincronización del cursor). Presence sincroniza estado (p. ej. usuarios en línea). Postgres Changes escucha cambios en la base de datos. Para elegir, pregúntate: ¿los datos deben persistir? ¿Es un evento o un estado?
¿Cómo recuperarse tras una desconexión WebSocket?
Supabase usa reconexión con backoff exponencial por defecto. También puedes personalizar la estrategia:

• 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?
Sí. Las suscripciones Realtime también siguen Row Level Security. Los usuarios solo reciben cambios que tienen permiso de ver; no hace falta escribir la lógica de seguridad dos veces.
¿Qué parámetros de configuración importan en producción?
Tres parámetros clave:

• 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?
Usa el parámetro filter para filtrar mensajes dentro de un canal, en lugar de crear un canal por tenant. Por ejemplo, filter: "tenant_id=eq.123" solo recibe cambios de ese tenant.
¿Cómo se compara Supabase Realtime con Pusher o Firebase?
La ventaja de Supabase es Postgres Changes escuchando la base directamente y RLS automático. La desventaja: curva de aprendizaje algo más pronunciada. Si ya usas Supabase Auth/Storage, Realtime encaja bien; si solo necesitas WebSocket simple, Pusher es más rápido de adoptar.

12 min de lectura · Publicado el: 12 may 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog