Supabase Realtime: Implementación de Funcionalidades Multiplayer Sin Gestionar Servidores WebSocket
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.