Saltar al contenido principal
Backend

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

6 min lectura
LD
Lucio Durán
Engineering Manager & AI Solutions Architect
También disponible en: English, Italiano

Las Tres Primitivas

Supabase 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

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á?

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

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

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

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:

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

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:

| 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:

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.

supabaserealtimewebsocketspostgrespresencemultiplayer

Herramientas mencionadas en este artículo

SupabaseProbá Supabase
VercelProbá Vercel
Divulgación: Algunos enlaces en este artículo son enlaces de afiliado. Si te registrás a través de ellos, puedo recibir una comisión sin costo adicional para vos. Solo recomiendo herramientas que uso y en las que confío personalmente.
Compartir
Seguime