Cambiar tema

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

Easton editorial illustration: three-option fit selector

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:

ModoDónde se guardan los datosCasos típicosPersistencia
Postgres ChangesPostgreSQLMensajes de chat, notificaciones, estado de pedidos
PresenceMemoria (servicio Realtime)Usuarios en línea, indicadores de escrituraNo
BroadcastNo se almacena (reenvío instantáneo)Movimiento del cursor, posiciones en tiempo realNo

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:

  1. 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.
  2. Parámetro filter: filtra por campo en la base de datos para evitar mensajes irrelevantes. Sintaxis: nombre_campo=eq.valor.
  3. Limpiar la suscripción: al desmontar el componente, llama a removeChannel o 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:

  1. Si la tabla está en la publication supabase_realtime
  2. 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

  1. Un solo Channel: las tres funciones comparten el mismo channel y reducen conexiones.
  2. Estructura de Presence: incluye is_typing en el mismo objeto presence.
  3. 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, removeChannel de 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:

  1. ¿La tabla está en la publication supabase_realtime?
  2. ¿Las políticas RLS son correctas? (usa la herramienta de prueba RLS del Dashboard de Supabase)
  3. ¿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
NoPostgres Changes
NoNoPresence
NoBroadcast

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?
Cada modo tiene su función:

• 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?
Revisa tres puntos:

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?
Si necesitas guardar historial y poder consultarlo después, elige Postgres Changes. Para datos temporales de alta frecuencia que no requieren persistencia, elige Broadcast. Por ejemplo: mensajes de chat con el primero, posición del cursor con el segundo.
¿Los datos de Presence se persisten?
No. Los datos de Presence se almacenan en la memoria del servicio Realtime y desaparecen cuando el usuario se desconecta. Son ideales para datos temporales como el estado en línea.
¿Cómo afecta la suscripción Realtime a RLS?
Las suscripciones Realtime aplican automáticamente las políticas RLS de la tabla. El usuario solo recibe cambios de datos que tiene permiso para ver. Es clave para la seguridad en producción; no filtres en el frontend.
¿Cuántos channels puedo usar en una página?
Se recomienda un máximo de 2-3. Las funciones relacionadas deben compartir un channel (por ejemplo, chat con Postgres Changes + Presence) para reducir conexiones WebSocket. Al salir de la página, llama siempre a removeChannel para limpiar.

14 min de lectura · Publicado el: 15 abr 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog