{"schemaVersion":1,"id":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer","title":"Supabase Realtime: Implementación de Funcionalidades Multiplayer Sin Gestionar Servidores WebSocket","description":"Presence tracking, broadcast channels, Postgres CDC y escalando conexiones WebSocket, una guía práctica para armar features multiplayer con Supabase Realtime.","language":"es","datePublished":"2026-02-19","dateModified":null,"contentFormat":"text/markdown","content":"Backend\n\n# Supabase Realtime: Implementación de Funcionalidades Multiplayer Sin Gestionar Servidores WebSocket\n\n2026-02-196 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/supabase-realtime-presence-multiplayer>)\n\n## Las Tres Primitivas\n\n[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:\n\n**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.\n\n**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.\n\n**3. Postgres Changes**, CDC (Change Data Capture) del WAL de Postgres. Cualquier INSERT, UPDATE, DELETE en una tabla suscripta pushea a los clientes conectados.\n\n## Armando un Editor de Documentos Colaborativo\n\n### Configurando el Canal\n\n```javascript\nimport { createClient } from '@supabase/supabase-js';\n\nconst supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {\n realtime: {\n params: {\n eventsPerSecond: 20,\n },\n },\n});\n\nconst channel = supabase.channel(`document:${documentId}`, {\n config: {\n broadcast: { self: false }, // No me echen de vuelta mis propios broadcasts\n presence: { key: userId }, // Presence key estable\n },\n});\n```\n\n### Presence: ¿Quién Anda Por Acá?\n\n```javascript\nchannel.subscribe(async (status) => {\n if (status === 'SUBSCRIBED') {\n await channel.track({\n userId: user.id,\n name: user.name,\n avatar: user.avatar_url,\n color: assignedColor,\n cursor: null,\n lastActive: new Date().toISOString(),\n });\n }\n});\n\nchannel.on('presence', { event: 'sync' }, () => {\n const presenceState = channel.presenceState();\n\n const activeUsers = Object.entries(presenceState).map(([key, values]) => ({\n ...values[0],\n presenceKey: key,\n }));\n\n setActiveUsers(activeUsers);\n});\n\nchannel.on('presence', { event: 'join' }, ({ key, newPresences }) => {\n showToast(`${newPresences[0].name} está viendo este documento`);\n});\n\nchannel.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {\n removeCursor(leftPresences[0].userId);\n});\n```\n\nEl ` 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ó.\n\n### Broadcast: Cursores en Vivo\n\n```javascript\nfunction handleMouseMove(e) {\n const position = editorToDocumentCoords(e.clientX, e.clientY);\n\n channel.send({\n type: 'broadcast',\n event: 'cursor',\n payload: {\n userId: user.id,\n x: position.x,\n y: position.y,\n selection: getCurrentSelection(),\n },\n });\n}\n\nchannel.on('broadcast', { event: 'cursor' }, ({ payload }) => {\n updateRemoteCursor(payload.userId, payload.x, payload.y, payload.selection);\n});\n\nlet typingTimeout;\nfunction handleKeyPress() {\n channel.send({\n type: 'broadcast',\n event: 'typing',\n payload: { userId: user.id, name: user.name },\n });\n\n clearTimeout(typingTimeout);\n typingTimeout = setTimeout(() => {\n channel.send({\n type: 'broadcast',\n event: 'stopped_typing',\n payload: { userId: user.id },\n });\n }, 2000);\n}\n```\n\nLos 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.\n\n### Postgres Changes: Estado Persistente del Documento\n\n```javascript\nchannel.on(\n 'postgres_changes',\n {\n event: '*',\n schema: 'public',\n table: 'document_blocks',\n filter: `document_id=eq.${documentId}`,\n },\n (payload) => {\n switch (payload.eventType) {\n case 'INSERT':\n insertBlock(payload.new);\n break;\n case 'UPDATE':\n updateBlock(payload.new, payload.old);\n break;\n case 'DELETE':\n deleteBlock(payload.old);\n break;\n }\n }\n);\n```\n\nEl filtro es esencial. Sin ` filter `, recibirías cambios para TODOS los documentos en la tabla ` document_blocks `.\n\n### Resolución de Conflictos: La Parte Difícil\n\n```javascript\nasync function saveBlockEdit(blockId, newContent, baseVersion) {\n const { data, error } = await supabase\n .from('document_blocks')\n .update({\n content: newContent,\n version: baseVersion + 1,\n last_edited_by: user.id,\n last_edited_at: new Date().toISOString(),\n })\n .eq('id', blockId)\n .eq('version', baseVersion) // Control de concurrencia optimista\n .select()\n .single();\n\n if (error) {\n const { data: current } = await supabase\n .from('document_blocks')\n .select('*')\n .eq('id', blockId)\n .single();\n\n const mergedContent = transformEdit(newContent, current.content, baseVersion);\n return saveBlockEdit(blockId, mergedContent, current.version);\n }\n\n return data;\n}\n```\n\nEl 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.\n\n## Consideraciones de Escalado\n\n### Gestión de Conexiones\n\nCada tab del browser abre una conexión WebSocket a Supabase Realtime. Si un usuario tiene tu app abierta en 3 tabs, son 3 conexiones:\n\n```javascript\nconst broadcastChannel = new BroadcastChannel('supabase-realtime');\n\nlet isLeader = false;\n\nbroadcastChannel.onmessage = (event) => {\n if (event.data.type === 'leader-heartbeat') {\n isLeader = false;\n }\n if (event.data.type === 'realtime-event') {\n handleRealtimeEvent(event.data.payload);\n }\n};\n\nsetTimeout(() => {\n if (!isLeader) {\n isLeader = true;\n setInterval(() => {\n broadcastChannel.postMessage({ type: 'leader-heartbeat' });\n }, 1000);\n initializeSupabaseRealtime();\n }\n}, Math.random() * 1000);\n```\n\n### Optimización a Nivel Base de Datos\n\n```sql\nCREATE PUBLICATION supabase_realtime FOR TABLE\n document_blocks,\n comments,\n activity_feed;\n\nCREATE INDEX idx_document_blocks_document_id\n ON document_blocks (document_id);\n```\n\n## Números de Performance Reales\n\nMedidos en una instancia Supabase Pro con frontend Next.js en [Vercel](<https://vercel.com/>):\n\n| Feature | Latencia (p50) | Latencia (p99) | Mensajes/seg |\n| --- | --- | --- | --- |\n| Broadcast (misma región) | 12ms | 28ms | 500+ por cliente |\n| Broadcast (cross-región) | 65ms | 120ms | 500+ por cliente |\n| Presence sync | 15ms | 40ms | N/A (basado en estado) |\n| Postgres Changes (filtrado) | 35ms | 85ms | Limitado por writes DB |\n| Postgres Changes (sin filtro) | 50ms | 150ms | Limitado por writes DB |\n\n## Edge Functions para Validación Server-Side\n\nNo confíes en eventos del lado del cliente. Cualquiera puede mandar mensajes broadcast arbitrarios:\n\n```typescript\nDeno.serve(async (req) => {\n const { gameId, playerId, move } = await req.json();\n\n const supabase = createClient(\n Deno.env.get('SUPABASE_URL')!,\n Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!\n );\n\n const { data: game } = await supabase\n .from('games')\n .select('*')\n .eq('id', gameId)\n .single();\n\n if (game.current_turn !== playerId) {\n return new Response(JSON.stringify({ error: 'No es tu turno' }), { status: 400 });\n }\n\n if (!isValidMove(game.board, move)) {\n return new Response(JSON.stringify({ error: 'Movimiento inválido' }), { status: 400 });\n }\n\n const { data: updated } = await supabase\n .from('games')\n .update({\n board: applyMove(game.board, move),\n current_turn: nextPlayer(game),\n move_history: [...game.move_history, { playerId, move, timestamp: new Date() }],\n })\n .eq('id', gameId)\n .select()\n .single();\n\n return new Response(JSON.stringify(updated));\n});\n```\n\n## La Arquitectura que Emergió\n\n```\n[Browser] ←→ [Supabase Realtime (WebSocket)]\n ├── Broadcast: cursores, tipeo, eventos efímeros\n ├── Presence: usuarios online, estado\n └── Postgres Changes: contenido de documento, game state\n ↑\n[Vercel Edge] → [Supabase Edge Functions] → [Postgres]\n Validación, auth, lógica de negocio\n```\n\nEl 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.\n\nDespué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.\n\n## Preguntas frecuentes\n\n### ¿Cuántas conexiones WebSocket concurrentes puede manejar Supabase Realtime?\n\nLa 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.\n\n### ¿Supabase Realtime es confiable para multiplayer en producción?\n\nPara 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.\n\n### ¿Cuál es la diferencia entre Broadcast y Postgres Changes?\n\nBroadcast 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.\n\n## Tips\n\n1. **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.\n2. **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.\n3. **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.\n4. **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.\n\n- supabase\n- realtime\n- websockets\n- postgres\n- presence\n- multiplayer\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)","headings":[{"level":1,"text":"Supabase Realtime: Implementación de Funcionalidades Multiplayer Sin Gestionar Servidores WebSocket","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#title"},{"level":2,"text":"Las Tres Primitivas","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#las-tres-primitivas"},{"level":2,"text":"Armando un Editor de Documentos Colaborativo","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#armando-un-editor-de-documentos-colaborativo"},{"level":3,"text":"Configurando el Canal","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#configurando-el-canal"},{"level":3,"text":"Presence: ¿Quién Anda Por Acá?","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#presence-quin-anda-por-ac"},{"level":3,"text":"Broadcast: Cursores en Vivo","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#broadcast-cursores-en-vivo"},{"level":3,"text":"Postgres Changes: Estado Persistente del Documento","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#postgres-changes-estado-persistente-del-documento"},{"level":3,"text":"Resolución de Conflictos: La Parte Difícil","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#resolucin-de-conflictos-la-parte-difcil"},{"level":2,"text":"Consideraciones de Escalado","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#consideraciones-de-escalado"},{"level":3,"text":"Gestión de Conexiones","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#gestin-de-conexiones"},{"level":3,"text":"Optimización a Nivel Base de Datos","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#optimizacin-a-nivel-base-de-datos"},{"level":2,"text":"Números de Performance Reales","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#nmeros-de-performance-reales"},{"level":2,"text":"Edge Functions para Validación Server-Side","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#edge-functions-para-validacin-server-side"},{"level":2,"text":"La Arquitectura que Emergió","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#la-arquitectura-que-emergi"},{"level":2,"text":"Preguntas frecuentes","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#questions"},{"level":3,"text":"¿Cuántas conexiones WebSocket concurrentes puede manejar Supabase Realtime?","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#question-1"},{"level":3,"text":"¿Supabase Realtime es confiable para multiplayer en producción?","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#question-2"},{"level":3,"text":"¿Cuál es la diferencia entre Broadcast y Postgres Changes?","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#article-tips"}],"sections":[{"heading":"Supabase Realtime: Implementación de Funcionalidades Multiplayer Sin Gestionar Servidores WebSocket","level":1,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer","content":"Backend"},{"heading":"Supabase Realtime: Implementación de Funcionalidades Multiplayer Sin Gestionar Servidores WebSocket","level":1,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#title","content":"2026-02-196 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/supabase-realtime-presence-multiplayer>)"},{"heading":"Las Tres Primitivas","level":2,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#las-tres-primitivas","content":"[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:\n\n**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.\n\n**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.\n\n**3. Postgres Changes**, CDC (Change Data Capture) del WAL de Postgres. Cualquier INSERT, UPDATE, DELETE en una tabla suscripta pushea a los clientes conectados."},{"heading":"Armando un Editor de Documentos Colaborativo","level":2,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#armando-un-editor-de-documentos-colaborativo","content":""},{"heading":"Configurando el Canal","level":3,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#configurando-el-canal","content":"```javascript\nimport { createClient } from '@supabase/supabase-js';\n\nconst supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {\n realtime: {\n params: {\n eventsPerSecond: 20,\n },\n },\n});\n\nconst channel = supabase.channel(`document:${documentId}`, {\n config: {\n broadcast: { self: false }, // No me echen de vuelta mis propios broadcasts\n presence: { key: userId }, // Presence key estable\n },\n});\n```"},{"heading":"Presence: ¿Quién Anda Por Acá?","level":3,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#presence-quin-anda-por-ac","content":"```javascript\nchannel.subscribe(async (status) => {\n if (status === 'SUBSCRIBED') {\n await channel.track({\n userId: user.id,\n name: user.name,\n avatar: user.avatar_url,\n color: assignedColor,\n cursor: null,\n lastActive: new Date().toISOString(),\n });\n }\n});\n\nchannel.on('presence', { event: 'sync' }, () => {\n const presenceState = channel.presenceState();\n\n const activeUsers = Object.entries(presenceState).map(([key, values]) => ({\n ...values[0],\n presenceKey: key,\n }));\n\n setActiveUsers(activeUsers);\n});\n\nchannel.on('presence', { event: 'join' }, ({ key, newPresences }) => {\n showToast(`${newPresences[0].name} está viendo este documento`);\n});\n\nchannel.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {\n removeCursor(leftPresences[0].userId);\n});\n```\n\nEl ` 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ó."},{"heading":"Broadcast: Cursores en Vivo","level":3,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#broadcast-cursores-en-vivo","content":"```javascript\nfunction handleMouseMove(e) {\n const position = editorToDocumentCoords(e.clientX, e.clientY);\n\n channel.send({\n type: 'broadcast',\n event: 'cursor',\n payload: {\n userId: user.id,\n x: position.x,\n y: position.y,\n selection: getCurrentSelection(),\n },\n });\n}\n\nchannel.on('broadcast', { event: 'cursor' }, ({ payload }) => {\n updateRemoteCursor(payload.userId, payload.x, payload.y, payload.selection);\n});\n\nlet typingTimeout;\nfunction handleKeyPress() {\n channel.send({\n type: 'broadcast',\n event: 'typing',\n payload: { userId: user.id, name: user.name },\n });\n\n clearTimeout(typingTimeout);\n typingTimeout = setTimeout(() => {\n channel.send({\n type: 'broadcast',\n event: 'stopped_typing',\n payload: { userId: user.id },\n });\n }, 2000);\n}\n```\n\nLos 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."},{"heading":"Postgres Changes: Estado Persistente del Documento","level":3,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#postgres-changes-estado-persistente-del-documento","content":"```javascript\nchannel.on(\n 'postgres_changes',\n {\n event: '*',\n schema: 'public',\n table: 'document_blocks',\n filter: `document_id=eq.${documentId}`,\n },\n (payload) => {\n switch (payload.eventType) {\n case 'INSERT':\n insertBlock(payload.new);\n break;\n case 'UPDATE':\n updateBlock(payload.new, payload.old);\n break;\n case 'DELETE':\n deleteBlock(payload.old);\n break;\n }\n }\n);\n```\n\nEl filtro es esencial. Sin ` filter `, recibirías cambios para TODOS los documentos en la tabla ` document_blocks `."},{"heading":"Resolución de Conflictos: La Parte Difícil","level":3,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#resolucin-de-conflictos-la-parte-difcil","content":"```javascript\nasync function saveBlockEdit(blockId, newContent, baseVersion) {\n const { data, error } = await supabase\n .from('document_blocks')\n .update({\n content: newContent,\n version: baseVersion + 1,\n last_edited_by: user.id,\n last_edited_at: new Date().toISOString(),\n })\n .eq('id', blockId)\n .eq('version', baseVersion) // Control de concurrencia optimista\n .select()\n .single();\n\n if (error) {\n const { data: current } = await supabase\n .from('document_blocks')\n .select('*')\n .eq('id', blockId)\n .single();\n\n const mergedContent = transformEdit(newContent, current.content, baseVersion);\n return saveBlockEdit(blockId, mergedContent, current.version);\n }\n\n return data;\n}\n```\n\nEl 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."},{"heading":"Consideraciones de Escalado","level":2,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#consideraciones-de-escalado","content":""},{"heading":"Gestión de Conexiones","level":3,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#gestin-de-conexiones","content":"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:\n\n```javascript\nconst broadcastChannel = new BroadcastChannel('supabase-realtime');\n\nlet isLeader = false;\n\nbroadcastChannel.onmessage = (event) => {\n if (event.data.type === 'leader-heartbeat') {\n isLeader = false;\n }\n if (event.data.type === 'realtime-event') {\n handleRealtimeEvent(event.data.payload);\n }\n};\n\nsetTimeout(() => {\n if (!isLeader) {\n isLeader = true;\n setInterval(() => {\n broadcastChannel.postMessage({ type: 'leader-heartbeat' });\n }, 1000);\n initializeSupabaseRealtime();\n }\n}, Math.random() * 1000);\n```"},{"heading":"Optimización a Nivel Base de Datos","level":3,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#optimizacin-a-nivel-base-de-datos","content":"```sql\nCREATE PUBLICATION supabase_realtime FOR TABLE\n document_blocks,\n comments,\n activity_feed;\n\nCREATE INDEX idx_document_blocks_document_id\n ON document_blocks (document_id);\n```"},{"heading":"Números de Performance Reales","level":2,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#nmeros-de-performance-reales","content":"Medidos en una instancia Supabase Pro con frontend Next.js en [Vercel](<https://vercel.com/>):\n\n| Feature | Latencia (p50) | Latencia (p99) | Mensajes/seg |\n| --- | --- | --- | --- |\n| Broadcast (misma región) | 12ms | 28ms | 500+ por cliente |\n| Broadcast (cross-región) | 65ms | 120ms | 500+ por cliente |\n| Presence sync | 15ms | 40ms | N/A (basado en estado) |\n| Postgres Changes (filtrado) | 35ms | 85ms | Limitado por writes DB |\n| Postgres Changes (sin filtro) | 50ms | 150ms | Limitado por writes DB |"},{"heading":"Edge Functions para Validación Server-Side","level":2,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#edge-functions-para-validacin-server-side","content":"No confíes en eventos del lado del cliente. Cualquiera puede mandar mensajes broadcast arbitrarios:\n\n```typescript\nDeno.serve(async (req) => {\n const { gameId, playerId, move } = await req.json();\n\n const supabase = createClient(\n Deno.env.get('SUPABASE_URL')!,\n Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!\n );\n\n const { data: game } = await supabase\n .from('games')\n .select('*')\n .eq('id', gameId)\n .single();\n\n if (game.current_turn !== playerId) {\n return new Response(JSON.stringify({ error: 'No es tu turno' }), { status: 400 });\n }\n\n if (!isValidMove(game.board, move)) {\n return new Response(JSON.stringify({ error: 'Movimiento inválido' }), { status: 400 });\n }\n\n const { data: updated } = await supabase\n .from('games')\n .update({\n board: applyMove(game.board, move),\n current_turn: nextPlayer(game),\n move_history: [...game.move_history, { playerId, move, timestamp: new Date() }],\n })\n .eq('id', gameId)\n .select()\n .single();\n\n return new Response(JSON.stringify(updated));\n});\n```"},{"heading":"La Arquitectura que Emergió","level":2,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#la-arquitectura-que-emergi","content":"```\n[Browser] ←→ [Supabase Realtime (WebSocket)]\n ├── Broadcast: cursores, tipeo, eventos efímeros\n ├── Presence: usuarios online, estado\n └── Postgres Changes: contenido de documento, game state\n ↑\n[Vercel Edge] → [Supabase Edge Functions] → [Postgres]\n Validación, auth, lógica de negocio\n```\n\nEl 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.\n\nDespué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."},{"heading":"Preguntas frecuentes","level":2,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#questions","content":""},{"heading":"¿Cuántas conexiones WebSocket concurrentes puede manejar Supabase Realtime?","level":3,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#question-1","content":"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."},{"heading":"¿Supabase Realtime es confiable para multiplayer en producción?","level":3,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#question-2","content":"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."},{"heading":"¿Cuál es la diferencia entre Broadcast y Postgres Changes?","level":3,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#question-3","content":"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."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#article-tips","content":"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.\n2. **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.\n3. **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.\n4. **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.\n\n- supabase\n- realtime\n- websockets\n- postgres\n- presence\n- multiplayer\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)"}],"links":[{"text":"English","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer"},{"text":"Supabase","url":"https://supabase.com/"},{"text":"Vercel","url":"https://vercel.com/"},{"text":"Volver al Blog","url":"https://lucioduran.com/es/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer"},"headline":"Supabase Realtime: Implementación de Funcionalidades Multiplayer Sin Gestionar Servidores WebSocket","description":"Presence tracking, broadcast channels, Postgres CDC y escalando conexiones WebSocket, una guía práctica para armar features multiplayer con Supabase Realtime.","datePublished":"2026-02-19","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://statics.lucioduran.com/images/lucioduran-social-card.jpg","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer","inLanguage":"es","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Backend","keywords":"supabase, realtime, websockets, postgres, presence, multiplayer","wordCount":1009,"timeRequired":"PT6M","workTranslation":[{"@type":"BlogPosting","inLanguage":"en","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Inicio","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/es/blog"},{"@type":"ListItem","position":3,"name":"Supabase Realtime: Implementación de Funcionalidades Multiplayer Sin Gestionar Servidores WebSocket","item":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"¿Cuántas conexiones WebSocket concurrentes puede manejar Supabase Realtime?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"¿Supabase Realtime es confiable para multiplayer en producción?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"¿Cuál es la diferencia entre Broadcast y Postgres Changes?","acceptedAnswer":{"@type":"Answer","text":"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."}}]}]}],"contentHash":"71551d9a2273519ce8049a5e839b88a70bd8b6b1e8c07396abedc2645c248168","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":64099,"contentBytes":11209}}
