<!-- Canonical: https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer
Language: es
Content-SHA256: 71551d9a2273519ce8049a5e839b88a70bd8b6b1e8c07396abedc2645c248168
-->

Backend

# Supabase Realtime: Implementación de Funcionalidades Multiplayer Sin Gestionar Servidores WebSocket

2026-02-196 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/supabase-realtime-presence-multiplayer>)

## Las Tres Primitivas

[Supabase](<https://supabase.com/>) Realtime ofrece tres primitivas distintas, y entender cuándo usar cada una es la diferencia entre una experiencia fluida y un desastre laggy:

**1. Broadcast**, Mensajería cliente-a-cliente a través del server. Sin persistencia. Sub-20ms de latencia. Considerar: posiciones de cursor, indicadores de tipeo, reacciones efímeras.

**2. Presence**, Estado compartido que trackea quién está online y su estado actual. Se sincroniza automáticamente cuando los usuarios se unen, se van o se desconectan.

**3. Postgres Changes**, CDC (Change Data Capture) del WAL de Postgres. Cualquier INSERT, UPDATE, DELETE en una tabla suscripta pushea a los clientes conectados.

## Armando un Editor de Documentos Colaborativo

### Configurando el Canal

```javascript
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
 realtime: {
 params: {
 eventsPerSecond: 20,
 },
 },
});

const channel = supabase.channel(`document:${documentId}`, {
 config: {
 broadcast: { self: false }, // No me echen de vuelta mis propios broadcasts
 presence: { key: userId }, // Presence key estable
 },
});
```

### Presence: ¿Quién Anda Por Acá?

```javascript
channel.subscribe(async (status) => {
 if (status === 'SUBSCRIBED') {
 await channel.track({
 userId: user.id,
 name: user.name,
 avatar: user.avatar_url,
 color: assignedColor,
 cursor: null,
 lastActive: new Date().toISOString(),
 });
 }
});

channel.on('presence', { event: 'sync' }, () => {
 const presenceState = channel.presenceState();

 const activeUsers = Object.entries(presenceState).map(([key, values]) => ({
 ...values[0],
 presenceKey: key,
 }));

 setActiveUsers(activeUsers);
});

channel.on('presence', { event: 'join' }, ({ key, newPresences }) => {
 showToast(`${newPresences[0].name} está viendo este documento`);
});

channel.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
 removeCursor(leftPresences[0].userId);
});
```

El ` presenceKey: userId ` en la config es crítico. Sin eso, Supabase genera una key random por conexión. Cuando la red del usuario hace un blip y reconecta, le toca una key nueva, así que la UI muestra que se fue y volvió.

### Broadcast: Cursores en Vivo

```javascript
function handleMouseMove(e) {
 const position = editorToDocumentCoords(e.clientX, e.clientY);

 channel.send({
 type: 'broadcast',
 event: 'cursor',
 payload: {
 userId: user.id,
 x: position.x,
 y: position.y,
 selection: getCurrentSelection(),
 },
 });
}

channel.on('broadcast', { event: 'cursor' }, ({ payload }) => {
 updateRemoteCursor(payload.userId, payload.x, payload.y, payload.selection);
});

let typingTimeout;
function handleKeyPress() {
 channel.send({
 type: 'broadcast',
 event: 'typing',
 payload: { userId: user.id, name: user.name },
 });

 clearTimeout(typingTimeout);
 typingTimeout = setTimeout(() => {
 channel.send({
 type: 'broadcast',
 event: 'stopped_typing',
 payload: { userId: user.id },
 });
 }, 2000);
}
```

Los mensajes Broadcast pasan por el server Realtime de Supabase pero nunca tocan la base. La latencia típica es 8-15ms entre clientes en la misma región.

### Postgres Changes: Estado Persistente del Documento

```javascript
channel.on(
 'postgres_changes',
 {
 event: '*',
 schema: 'public',
 table: 'document_blocks',
 filter: `document_id=eq.${documentId}`,
 },
 (payload) => {
 switch (payload.eventType) {
 case 'INSERT':
 insertBlock(payload.new);
 break;
 case 'UPDATE':
 updateBlock(payload.new, payload.old);
 break;
 case 'DELETE':
 deleteBlock(payload.old);
 break;
 }
 }
);
```

El filtro es esencial. Sin ` filter `, recibirías cambios para TODOS los documentos en la tabla ` document_blocks `.

### Resolución de Conflictos: La Parte Difícil

```javascript
async function saveBlockEdit(blockId, newContent, baseVersion) {
 const { data, error } = await supabase
 .from('document_blocks')
 .update({
 content: newContent,
 version: baseVersion + 1,
 last_edited_by: user.id,
 last_edited_at: new Date().toISOString(),
 })
 .eq('id', blockId)
 .eq('version', baseVersion) // Control de concurrencia optimista
 .select()
 .single();

 if (error) {
 const { data: current } = await supabase
 .from('document_blocks')
 .select('*')
 .eq('id', blockId)
 .single();

 const mergedContent = transformEdit(newContent, current.content, baseVersion);
 return saveBlockEdit(blockId, mergedContent, current.version);
 }

 return data;
}
```

El control de concurrencia optimista via ` .eq('version', baseVersion) ` asegura que si alguien más actualizó el bloque entre nuestro read y write, el update falla y podemos mergear apropiadamente.

## Consideraciones de Escalado

### Gestión de Conexiones

Cada tab del browser abre una conexión WebSocket a Supabase Realtime. Si un usuario tiene tu app abierta en 3 tabs, son 3 conexiones:

```javascript
const broadcastChannel = new BroadcastChannel('supabase-realtime');

let isLeader = false;

broadcastChannel.onmessage = (event) => {
 if (event.data.type === 'leader-heartbeat') {
 isLeader = false;
 }
 if (event.data.type === 'realtime-event') {
 handleRealtimeEvent(event.data.payload);
 }
};

setTimeout(() => {
 if (!isLeader) {
 isLeader = true;
 setInterval(() => {
 broadcastChannel.postMessage({ type: 'leader-heartbeat' });
 }, 1000);
 initializeSupabaseRealtime();
 }
}, Math.random() * 1000);
```

### Optimización a Nivel Base de Datos

```sql
CREATE PUBLICATION supabase_realtime FOR TABLE
 document_blocks,
 comments,
 activity_feed;

CREATE INDEX idx_document_blocks_document_id
 ON document_blocks (document_id);
```

## Números de Performance Reales

Medidos en una instancia Supabase Pro con frontend Next.js en [Vercel](<https://vercel.com/>):

| Feature | Latencia (p50) | Latencia (p99) | Mensajes/seg |
| --- | --- | --- | --- |
| Broadcast (misma región) | 12ms | 28ms | 500+ por cliente |
| Broadcast (cross-región) | 65ms | 120ms | 500+ por cliente |
| Presence sync | 15ms | 40ms | N/A (basado en estado) |
| Postgres Changes (filtrado) | 35ms | 85ms | Limitado por writes DB |
| Postgres Changes (sin filtro) | 50ms | 150ms | Limitado por writes DB |

## Edge Functions para Validación Server-Side

No confíes en eventos del lado del cliente. Cualquiera puede mandar mensajes broadcast arbitrarios:

```typescript
Deno.serve(async (req) => {
 const { gameId, playerId, move } = await req.json();

 const supabase = createClient(
 Deno.env.get('SUPABASE_URL')!,
 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
 );

 const { data: game } = await supabase
 .from('games')
 .select('*')
 .eq('id', gameId)
 .single();

 if (game.current_turn !== playerId) {
 return new Response(JSON.stringify({ error: 'No es tu turno' }), { status: 400 });
 }

 if (!isValidMove(game.board, move)) {
 return new Response(JSON.stringify({ error: 'Movimiento inválido' }), { status: 400 });
 }

 const { data: updated } = await supabase
 .from('games')
 .update({
 board: applyMove(game.board, move),
 current_turn: nextPlayer(game),
 move_history: [...game.move_history, { playerId, move, timestamp: new Date() }],
 })
 .eq('id', gameId)
 .select()
 .single();

 return new Response(JSON.stringify(updated));
});
```

## La Arquitectura que Emergió

```
[Browser] ←→ [Supabase Realtime (WebSocket)]
 ├── Broadcast: cursores, tipeo, eventos efímeros
 ├── Presence: usuarios online, estado
 └── Postgres Changes: contenido de documento, game state
 ↑
[Vercel Edge] → [Supabase Edge Functions] → [Postgres]
 Validación, auth, lógica de negocio
```

El modelo mental: Broadcast es un relay WebSocket, Presence es una hash table distribuida, y Postgres Changes es un sistema de push notifications para tu base de datos. Combinar los tres y teners una plataforma real-time sorprendentemente capaz sin manejar infraestructura.

Después de seis meses en producción con ~300 usuarios activos diarios, los features de colaboración han sido sólidos. Tuvimos dos caídas de Supabase Realtime (ambas menos de 5 minutos), cero pérdida de datos (porque el estado persistente está en Postgres, no en la capa WebSocket), y nuestra cuenta de Supabase es $25/mes por el tier Pro. La infraestructura custom equivalente, un server WebSocket, Redis para presence, un consumer de change stream, costaría 10x más en dinero y tiempo de mantenimiento.

## Preguntas frecuentes

### ¿Cuántas conexiones WebSocket concurrentes puede manejar Supabase Realtime?

La plataforma Supabase manejada soporta hasta 500 conexiones concurrentes en el tier Free, 10.000 en Pro, y 200.000+ en Enterprise con escalado horizontal. Self-hosted, un solo nodo Realtime maneja ~50.000 conexiones con settings default en una máquina de 4 cores.

### ¿Supabase Realtime es confiable para multiplayer en producción?

Para multiplayer casual (edición colaborativa, presencia, juegos por turnos), sí. Para juegos competitivos en tiempo real que requieren tick rates de \<50ms del lado del servidor, no, utilizar servidores de juego dedicados. Supabase Realtime agrega 20-80ms de latencia según el feature.

### ¿Cuál es la diferencia entre Broadcast y Postgres Changes?

Broadcast manda mensajes directamente entre clientes a través del server Realtime, sin base de datos involucrada, sub-20ms de latencia, pero los mensajes son efímeros. Postgres Changes escucha el WAL y pushea cambios de base de datos a clientes suscriptos, 30-80ms de latencia, pero los cambios son persistentes.

## Tips

1. **LuchoTip:** Utilizar ` Broadcast ` para eventos efímeros (movimientos de cursor, indicadores de tipeo) y ` Postgres Changes ` para estado persistente. Veo desarrolladores ruteando todo por Postgres CDC cuando la mitad de sus eventos no necesitan persistencia, agrega 15-30ms de latencia y carga innecesaria de WAL.
2. **LuchoTip:** Configurar ` presenceKey ` a un identificador estable de usuario en vez de dejar que Supabase genere uno. Las presence keys default cambian en reconexión, así que cada blip de red parece un usuario yéndose y volviendo. Utilizar ` channel.track({ userId: user.id }) ` y tu lista de presencia se mantiene estable.
3. **LuchoTip:** Cuando escuchás Postgres Changes, siempre filtrar con ` .eq('column', 'value') ` en la suscripción del canal. Sin filtros, recibís TODOS los cambios de la tabla de TODOS los usuarios. Una vez shippeé un editor colaborativo donde cada usuario recibía los cambios de documento de todos los demás. La cuenta de Supabase ese mes fue educativa.
4. **LuchoTip:** Implementar exponential backoff en tu lógica de reconexión Realtime. El cliente default de Supabase reconecta inmediatamente, lo que durante una caída regional significa miles de clientes martillando el cluster Realtime simultáneamente.

- supabase
- realtime
- websockets
- postgres
- presence
- multiplayer

[Volver al Blog](<https://lucioduran.com/es/blog>)
