{"schemaVersion":1,"id":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer","title":"Supabase Realtime: Building Multiplayer Features Without Running a Single WebSocket Server","description":"Presence tracking, broadcast channels, Postgres CDC, and scaling WebSocket connections, a practical guide to building multiplayer features with Supabase Realtime.","language":"en","datePublished":"2026-02-19","dateModified":null,"contentFormat":"text/markdown","content":"Backend\n\n# Supabase Realtime: Building Multiplayer Features Without Running a Single WebSocket Server\n\n2026-02-198 min read\n\n[Lucio Durán](<https://lucioduran.com/>)\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer>)\n\n## The Three Primitives\n\n[Supabase](<https://supabase.com/>) Realtime gives you three distinct primitives, and understanding when to use each is the difference between a smooth experience and a laggy mess:\n\n**1. Broadcast**, Client-to-client messaging through the server. No persistence. Sub-20ms latency. Think: cursor positions, typing indicators, ephemeral reactions.\n\n**2. Presence**, Shared state that tracks who's online and their current status. Automatically synced when users join, leave, or disconnect. Think: \"5 users viewing this document,\" online indicators.\n\n**3. Postgres Changes**, CDC (Change Data Capture) from your Postgres WAL. Any INSERT, UPDATE, DELETE on a subscribed table pushes to connected clients. Think: live feeds, collaborative editing persistence, real-time dashboards.\n\n## Building a Collaborative Document Editor\n\nLet me walk through the real implementation. The document editor needs:\n\n- Live cursor positions (Broadcast)\n- Who's currently editing (Presence)\n- Persistent document changes (Postgres Changes)\n\n### Setting Up the Channel\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, // Rate limit outgoing events\n },\n },\n});\n\n// One channel per document\nconst channel = supabase.channel(`document:${documentId}`, {\n config: {\n broadcast: { self: false }, // Don't echo my own broadcasts back\n presence: { key: userId }, // Stable presence key\n },\n});\n```\n\n### Presence: Who's Here?\n\n```javascript\n// Track this user's presence\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\n// Listen for presence changes\nchannel.on('presence', { event: 'sync' }, () => {\n const presenceState = channel.presenceState();\n\n // Convert to array of active users\n const activeUsers = Object.entries(presenceState).map(([key, values]) => ({\n ...values[0], // Most recent presence for this key\n presenceKey: key,\n }));\n\n setActiveUsers(activeUsers);\n});\n\nchannel.on('presence', { event: 'join' }, ({ key, newPresences }) => {\n console.log(`${newPresences[0].name} joined the document`);\n showToast(`${newPresences[0].name} is now viewing this document`);\n});\n\nchannel.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {\n console.log(`${leftPresences[0].name} left the document`);\n removeCursor(leftPresences[0].userId);\n});\n```\n\nThe ` presenceKey: userId ` in the config is critical. Without it, Supabase generates a random key per connection. When a user's network blips and they reconnect, they get a new key, so the UI shows them leaving and rejoining. With a stable user ID as the key, reconnections update the existing presence entry instead of creating a new one.\n\n### Broadcast: Live Cursors\n\n```javascript\n// Send cursor position (throttled to 20fps by eventsPerSecond config)\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(), // Text selection range, if any\n },\n });\n}\n\n// Receive other users' cursors\nchannel.on('broadcast', { event: 'cursor' }, ({ payload }) => {\n updateRemoteCursor(payload.userId, payload.x, payload.y, payload.selection);\n});\n\n// Typing indicator via broadcast\nlet typingTimeout;\nfunction handleKeyPress() {\n channel.send({\n type: 'broadcast',\n event: 'typing',\n payload: { userId: user.id, name: user.name },\n });\n\n // Clear typing indicator after 2 seconds of inactivity\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\nBroadcast messages go through the Supabase Realtime server but never touch the database. Latency is typically 8-15ms between clients in the same region. For cursor positions at 20fps, that's imperceptible.\n\n### Postgres Changes: Persistent Document State\n\n```javascript\n// Listen for document changes from the database\nchannel.on(\n 'postgres_changes',\n {\n event: '*', // INSERT, UPDATE, DELETE\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\nThe filter is essential. Without ` filter: \\ `document\\_id=eq.${documentId}\\`` , you'd receive changes for ALL documents in the  `document\\_blocks\\` table. On a busy app, that's a firehose of irrelevant data.\n\n### Conflict Resolution: The Hard Part\n\nWhen two users edit the same block simultaneously, you need a merge strategy. I use operational transformation (OT), not because it's the best approach (CRDTs are theoretically superior), but because it's simpler to implement correctly for text editing:\n\n```javascript\n// When saving a local change\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) // Optimistic concurrency control\n .select()\n .single();\n\n if (error) {\n // Version mismatch, someone else edited this block\n const { data: current } = await supabase\n .from('document_blocks')\n .select('*')\n .eq('id', blockId)\n .single();\n\n // Merge: transform our edit against the remote edit\n const mergedContent = transformEdit(newContent, current.content, baseVersion);\n return saveBlockEdit(blockId, mergedContent, current.version);\n }\n\n return data;\n}\n```\n\nThe optimistic concurrency control via ` .eq('version', baseVersion) ` ensures that if someone else updated the block between our read and write, the update fails and we can merge properly. Postgres guarantees this is atomic, no race conditions, no lost updates.\n\n## Scaling Considerations\n\n### Connection Management\n\nEach browser tab opens one WebSocket connection to Supabase Realtime. If a user has your app open in 3 tabs, that's 3 connections. At scale, this adds up fast.\n\n```javascript\n// Detect duplicate tabs and share a single connection\nconst broadcastChannel = new BroadcastChannel('supabase-realtime');\n\n// Leader election, only one tab maintains the WebSocket\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 // Forward events from leader tab to follower tabs\n handleRealtimeEvent(event.data.payload);\n }\n};\n\n// Try to become leader\nsetTimeout(() => {\n if (!isLeader) {\n isLeader = true;\n setInterval(() => {\n broadcastChannel.postMessage({ type: 'leader-heartbeat' });\n }, 1000);\n initializeSupabaseRealtime(); // Only the leader tab connects\n }\n}, Math.random() * 1000); // Random delay to avoid conflicts\n```\n\n### Database-Level Optimization\n\nPostgres CDC works by reading the WAL (Write-Ahead Log). Every change to a subscribed table generates a WAL event that Supabase Realtime processes. On a busy table with frequent writes, this can lag:\n\n```sql\n-- Create a publication for only the tables you need CDC on\n-- This prevents Realtime from processing WAL events for unrelated tables\nCREATE PUBLICATION supabase_realtime FOR TABLE\n document_blocks,\n comments,\n activity_feed;\n\n-- Add an index for the filter column to speed up server-side filtering\nCREATE INDEX idx_document_blocks_document_id\n ON document_blocks (document_id);\n\n-- Reduce WAL volume by only including changed columns\nALTER TABLE document_blocks REPLICA IDENTITY DEFAULT;\n-- Or for more control:\n-- ALTER TABLE document_blocks REPLICA IDENTITY FULL;\n```\n\n### Rate Limiting Outgoing Events\n\n```javascript\n// Custom throttle that batches cursor updates\nfunction createCursorThrottle(channel, userId, fps = 15) {\n let lastPosition = null;\n let scheduled = false;\n\n return function throttledCursorUpdate(x, y, selection) {\n lastPosition = { x, y, selection };\n\n if (!scheduled) {\n scheduled = true;\n requestAnimationFrame(() => {\n if (lastPosition) {\n channel.send({\n type: 'broadcast',\n event: 'cursor',\n payload: { userId, ...lastPosition },\n });\n lastPosition = null;\n }\n scheduled = false;\n });\n }\n };\n}\n```\n\n## Real-World Performance Numbers\n\nI measured these on a Supabase Pro instance with a Next.js frontend deployed on [Vercel](<https://vercel.com/>):\n\n| Feature | Latency (p50) | Latency (p99) | Messages/sec |\n| --- | --- | --- | --- |\n| Broadcast (same region) | 12ms | 28ms | 500+ per client |\n| Broadcast (cross-region) | 65ms | 120ms | 500+ per client |\n| Presence sync | 15ms | 40ms | N/A (state-based) |\n| Postgres Changes (filtered) | 35ms | 85ms | Limited by DB writes |\n| Postgres Changes (unfiltered) | 50ms | 150ms | Limited by DB writes |\n\nThe cross-region broadcast latency (65ms p50) is worth noting. If your users are globally distributed and need sub-30ms latency, Supabase Realtime might not be sufficient, consider Cloudflare Durable Objects or a dedicated WebSocket infrastructure. But for most collaborative tools where 65ms is imperceptible to humans (document editing, project management boards, chat), it's more than adequate.\n\n## Edge Functions for Server-Side Validation\n\nDon't trust client-side events. Anyone can send arbitrary broadcast messages. For events that affect game state or business logic, validate through Supabase Edge Functions:\n\n```typescript\n// supabase/functions/validate-move/index.ts\nimport { createClient } from 'https://esm.sh/@supabase/supabase-js@2';\n\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 // Fetch current game state\n const { data: game } = await supabase\n .from('games')\n .select('*')\n .eq('id', gameId)\n .single();\n\n // Validate the move server-side\n if (game.current_turn !== playerId) {\n return new Response(JSON.stringify({ error: 'Not your turn' }), { status: 400 });\n }\n\n if (!isValidMove(game.board, move)) {\n return new Response(JSON.stringify({ error: 'Invalid move' }), { status: 400 });\n }\n\n // Apply the move, this triggers Postgres Changes for all subscribers\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## The Architecture That Emerged\n\n```\n[Browser] ←→ [Supabase Realtime (WebSocket)]\n ├── Broadcast: cursors, typing, ephemeral events\n ├── Presence: online users, status\n └── Postgres Changes: document content, game state\n ↑\n[Vercel Edge] → [Supabase Edge Functions] → [Postgres]\n Validation, auth, business logic\n```\n\nThe mental model: Broadcast is a WebSocket relay, Presence is a distributed hash table, and Postgres Changes is a push notification system for your database. Layer them together and you get a surprisingly capable real-time platform without managing any infrastructure.\n\nAfter six months in production with ~300 daily active users, the collaboration features have been solid. We've had two Supabase Realtime outages (both under 5 minutes), zero data loss (because the persistent state is in Postgres, not in the WebSocket layer), and our Supabase bill is $25/month for the Pro tier. The equivalent custom infrastructure, a WebSocket server, Redis for presence, a change stream consumer, would cost 10x more in both money and maintenance time.\n\n## Frequently asked questions\n\n### How many concurrent WebSocket connections can Supabase Realtime handle?\n\nThe managed Supabase platform handles up to 500 concurrent connections on the Free tier, 10,000 on Pro, and 200,000+ on Enterprise with horizontal scaling. Self-hosted, a single Realtime node handles ~50,000 connections with default settings on a 4-core machine. The bottleneck is usually Postgres CDC throughput, not WebSocket connections.\n\n### Is Supabase Realtime reliable enough for production multiplayer?\n\nFor casual multiplayer (collaborative editing, presence, turn-based games), yes. For competitive real-time games requiring \\<50ms server-side tick rates, no, use dedicated game servers. Supabase Realtime adds 20-80ms of latency depending on the feature (Broadcast is fastest, Postgres Changes is slowest).\n\n### What's the difference between Broadcast and Postgres Changes?\n\nBroadcast sends messages directly between clients through the Realtime server, no database involved, sub-20ms latency, but messages are ephemeral (lost if no one is listening). Postgres Changes listens to the WAL (Write-Ahead Log) and pushes database changes to subscribed clients, 30-80ms latency, but changes are persisted and you get the full transactional guarantees of Postgres.\n\n## Tips\n\n1. **LuchoTip:** Use ` Broadcast ` for ephemeral events (cursor moves, typing indicators) and ` Postgres Changes ` for persistent state. I see developers routing everything through Postgres CDC when half their events don't need persistence, it adds 15-30ms latency and unnecessary WAL load.\n2. **LuchoTip:** Set ` presenceKey ` to a stable user identifier instead of letting Supabase generate one. Default presence keys change on reconnect, so every network hiccup looks like a user leaving and rejoining. Use ` channel.track({ userId: user.id }) ` and your presence list stays stable.\n3. **LuchoTip:** When listening to Postgres Changes, always filter with ` .eq('column', 'value') ` in the channel subscription. Without filters, you receive ALL changes to the table across ALL users. I once shipped a collaborative editor where every user received every other user's document changes. The Supabase bill that month was educational.\n4. **LuchoTip:** Implement exponential backoff on your Realtime reconnection logic. Supabase's default client reconnects immediately, which during a regional outage means thousands of clients hammering the Realtime cluster simultaneously. Add jitter: ` Math.min(1000 * 2^attempt + Math.random() * 1000, 30000) `.\n\n- supabase\n- realtime\n- websockets\n- postgres\n- presence\n- multiplayer\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Supabase Realtime: Building Multiplayer Features Without Running a Single WebSocket Server","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#title"},{"level":2,"text":"The Three Primitives","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#the-three-primitives"},{"level":2,"text":"Building a Collaborative Document Editor","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#building-a-collaborative-document-editor"},{"level":3,"text":"Setting Up the Channel","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#setting-up-the-channel"},{"level":3,"text":"Presence: Who's Here?","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#presence-whos-here"},{"level":3,"text":"Broadcast: Live Cursors","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#broadcast-live-cursors"},{"level":3,"text":"Postgres Changes: Persistent Document State","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#postgres-changes-persistent-document-state"},{"level":3,"text":"Conflict Resolution: The Hard Part","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#conflict-resolution-the-hard-part"},{"level":2,"text":"Scaling Considerations","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#scaling-considerations"},{"level":3,"text":"Connection Management","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#connection-management"},{"level":3,"text":"Database-Level Optimization","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#database-level-optimization"},{"level":3,"text":"Rate Limiting Outgoing Events","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#rate-limiting-outgoing-events"},{"level":2,"text":"Real-World Performance Numbers","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#real-world-performance-numbers"},{"level":2,"text":"Edge Functions for Server-Side Validation","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#edge-functions-for-server-side-validation"},{"level":2,"text":"The Architecture That Emerged","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#the-architecture-that-emerged"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#questions"},{"level":3,"text":"How many concurrent WebSocket connections can Supabase Realtime handle?","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#question-1"},{"level":3,"text":"Is Supabase Realtime reliable enough for production multiplayer?","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#question-2"},{"level":3,"text":"What's the difference between Broadcast and Postgres Changes?","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#article-tips"}],"sections":[{"heading":"Supabase Realtime: Building Multiplayer Features Without Running a Single WebSocket Server","level":1,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer","content":"Backend"},{"heading":"Supabase Realtime: Building Multiplayer Features Without Running a Single WebSocket Server","level":1,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#title","content":"2026-02-198 min read\n\n[Lucio Durán](<https://lucioduran.com/>)\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer>)"},{"heading":"The Three Primitives","level":2,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#the-three-primitives","content":"[Supabase](<https://supabase.com/>) Realtime gives you three distinct primitives, and understanding when to use each is the difference between a smooth experience and a laggy mess:\n\n**1. Broadcast**, Client-to-client messaging through the server. No persistence. Sub-20ms latency. Think: cursor positions, typing indicators, ephemeral reactions.\n\n**2. Presence**, Shared state that tracks who's online and their current status. Automatically synced when users join, leave, or disconnect. Think: \"5 users viewing this document,\" online indicators.\n\n**3. Postgres Changes**, CDC (Change Data Capture) from your Postgres WAL. Any INSERT, UPDATE, DELETE on a subscribed table pushes to connected clients. Think: live feeds, collaborative editing persistence, real-time dashboards."},{"heading":"Building a Collaborative Document Editor","level":2,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#building-a-collaborative-document-editor","content":"Let me walk through the real implementation. The document editor needs:\n\n- Live cursor positions (Broadcast)\n- Who's currently editing (Presence)\n- Persistent document changes (Postgres Changes)"},{"heading":"Setting Up the Channel","level":3,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#setting-up-the-channel","content":"```javascript\nimport { createClient } from '@supabase/supabase-js';\n\nconst supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {\n realtime: {\n params: {\n eventsPerSecond: 20, // Rate limit outgoing events\n },\n },\n});\n\n// One channel per document\nconst channel = supabase.channel(`document:${documentId}`, {\n config: {\n broadcast: { self: false }, // Don't echo my own broadcasts back\n presence: { key: userId }, // Stable presence key\n },\n});\n```"},{"heading":"Presence: Who's Here?","level":3,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#presence-whos-here","content":"```javascript\n// Track this user's presence\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\n// Listen for presence changes\nchannel.on('presence', { event: 'sync' }, () => {\n const presenceState = channel.presenceState();\n\n // Convert to array of active users\n const activeUsers = Object.entries(presenceState).map(([key, values]) => ({\n ...values[0], // Most recent presence for this key\n presenceKey: key,\n }));\n\n setActiveUsers(activeUsers);\n});\n\nchannel.on('presence', { event: 'join' }, ({ key, newPresences }) => {\n console.log(`${newPresences[0].name} joined the document`);\n showToast(`${newPresences[0].name} is now viewing this document`);\n});\n\nchannel.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {\n console.log(`${leftPresences[0].name} left the document`);\n removeCursor(leftPresences[0].userId);\n});\n```\n\nThe ` presenceKey: userId ` in the config is critical. Without it, Supabase generates a random key per connection. When a user's network blips and they reconnect, they get a new key, so the UI shows them leaving and rejoining. With a stable user ID as the key, reconnections update the existing presence entry instead of creating a new one."},{"heading":"Broadcast: Live Cursors","level":3,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#broadcast-live-cursors","content":"```javascript\n// Send cursor position (throttled to 20fps by eventsPerSecond config)\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(), // Text selection range, if any\n },\n });\n}\n\n// Receive other users' cursors\nchannel.on('broadcast', { event: 'cursor' }, ({ payload }) => {\n updateRemoteCursor(payload.userId, payload.x, payload.y, payload.selection);\n});\n\n// Typing indicator via broadcast\nlet typingTimeout;\nfunction handleKeyPress() {\n channel.send({\n type: 'broadcast',\n event: 'typing',\n payload: { userId: user.id, name: user.name },\n });\n\n // Clear typing indicator after 2 seconds of inactivity\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\nBroadcast messages go through the Supabase Realtime server but never touch the database. Latency is typically 8-15ms between clients in the same region. For cursor positions at 20fps, that's imperceptible."},{"heading":"Postgres Changes: Persistent Document State","level":3,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#postgres-changes-persistent-document-state","content":"```javascript\n// Listen for document changes from the database\nchannel.on(\n 'postgres_changes',\n {\n event: '*', // INSERT, UPDATE, DELETE\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\nThe filter is essential. Without ` filter: \\ `document\\_id=eq.${documentId}\\`` , you'd receive changes for ALL documents in the  `document\\_blocks\\` table. On a busy app, that's a firehose of irrelevant data."},{"heading":"Conflict Resolution: The Hard Part","level":3,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#conflict-resolution-the-hard-part","content":"When two users edit the same block simultaneously, you need a merge strategy. I use operational transformation (OT), not because it's the best approach (CRDTs are theoretically superior), but because it's simpler to implement correctly for text editing:\n\n```javascript\n// When saving a local change\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) // Optimistic concurrency control\n .select()\n .single();\n\n if (error) {\n // Version mismatch, someone else edited this block\n const { data: current } = await supabase\n .from('document_blocks')\n .select('*')\n .eq('id', blockId)\n .single();\n\n // Merge: transform our edit against the remote edit\n const mergedContent = transformEdit(newContent, current.content, baseVersion);\n return saveBlockEdit(blockId, mergedContent, current.version);\n }\n\n return data;\n}\n```\n\nThe optimistic concurrency control via ` .eq('version', baseVersion) ` ensures that if someone else updated the block between our read and write, the update fails and we can merge properly. Postgres guarantees this is atomic, no race conditions, no lost updates."},{"heading":"Scaling Considerations","level":2,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#scaling-considerations","content":""},{"heading":"Connection Management","level":3,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#connection-management","content":"Each browser tab opens one WebSocket connection to Supabase Realtime. If a user has your app open in 3 tabs, that's 3 connections. At scale, this adds up fast.\n\n```javascript\n// Detect duplicate tabs and share a single connection\nconst broadcastChannel = new BroadcastChannel('supabase-realtime');\n\n// Leader election, only one tab maintains the WebSocket\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 // Forward events from leader tab to follower tabs\n handleRealtimeEvent(event.data.payload);\n }\n};\n\n// Try to become leader\nsetTimeout(() => {\n if (!isLeader) {\n isLeader = true;\n setInterval(() => {\n broadcastChannel.postMessage({ type: 'leader-heartbeat' });\n }, 1000);\n initializeSupabaseRealtime(); // Only the leader tab connects\n }\n}, Math.random() * 1000); // Random delay to avoid conflicts\n```"},{"heading":"Database-Level Optimization","level":3,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#database-level-optimization","content":"Postgres CDC works by reading the WAL (Write-Ahead Log). Every change to a subscribed table generates a WAL event that Supabase Realtime processes. On a busy table with frequent writes, this can lag:\n\n```sql\n-- Create a publication for only the tables you need CDC on\n-- This prevents Realtime from processing WAL events for unrelated tables\nCREATE PUBLICATION supabase_realtime FOR TABLE\n document_blocks,\n comments,\n activity_feed;\n\n-- Add an index for the filter column to speed up server-side filtering\nCREATE INDEX idx_document_blocks_document_id\n ON document_blocks (document_id);\n\n-- Reduce WAL volume by only including changed columns\nALTER TABLE document_blocks REPLICA IDENTITY DEFAULT;\n-- Or for more control:\n-- ALTER TABLE document_blocks REPLICA IDENTITY FULL;\n```"},{"heading":"Rate Limiting Outgoing Events","level":3,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#rate-limiting-outgoing-events","content":"```javascript\n// Custom throttle that batches cursor updates\nfunction createCursorThrottle(channel, userId, fps = 15) {\n let lastPosition = null;\n let scheduled = false;\n\n return function throttledCursorUpdate(x, y, selection) {\n lastPosition = { x, y, selection };\n\n if (!scheduled) {\n scheduled = true;\n requestAnimationFrame(() => {\n if (lastPosition) {\n channel.send({\n type: 'broadcast',\n event: 'cursor',\n payload: { userId, ...lastPosition },\n });\n lastPosition = null;\n }\n scheduled = false;\n });\n }\n };\n}\n```"},{"heading":"Real-World Performance Numbers","level":2,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#real-world-performance-numbers","content":"I measured these on a Supabase Pro instance with a Next.js frontend deployed on [Vercel](<https://vercel.com/>):\n\n| Feature | Latency (p50) | Latency (p99) | Messages/sec |\n| --- | --- | --- | --- |\n| Broadcast (same region) | 12ms | 28ms | 500+ per client |\n| Broadcast (cross-region) | 65ms | 120ms | 500+ per client |\n| Presence sync | 15ms | 40ms | N/A (state-based) |\n| Postgres Changes (filtered) | 35ms | 85ms | Limited by DB writes |\n| Postgres Changes (unfiltered) | 50ms | 150ms | Limited by DB writes |\n\nThe cross-region broadcast latency (65ms p50) is worth noting. If your users are globally distributed and need sub-30ms latency, Supabase Realtime might not be sufficient, consider Cloudflare Durable Objects or a dedicated WebSocket infrastructure. But for most collaborative tools where 65ms is imperceptible to humans (document editing, project management boards, chat), it's more than adequate."},{"heading":"Edge Functions for Server-Side Validation","level":2,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#edge-functions-for-server-side-validation","content":"Don't trust client-side events. Anyone can send arbitrary broadcast messages. For events that affect game state or business logic, validate through Supabase Edge Functions:\n\n```typescript\n// supabase/functions/validate-move/index.ts\nimport { createClient } from 'https://esm.sh/@supabase/supabase-js@2';\n\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 // Fetch current game state\n const { data: game } = await supabase\n .from('games')\n .select('*')\n .eq('id', gameId)\n .single();\n\n // Validate the move server-side\n if (game.current_turn !== playerId) {\n return new Response(JSON.stringify({ error: 'Not your turn' }), { status: 400 });\n }\n\n if (!isValidMove(game.board, move)) {\n return new Response(JSON.stringify({ error: 'Invalid move' }), { status: 400 });\n }\n\n // Apply the move, this triggers Postgres Changes for all subscribers\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":"The Architecture That Emerged","level":2,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#the-architecture-that-emerged","content":"```\n[Browser] ←→ [Supabase Realtime (WebSocket)]\n ├── Broadcast: cursors, typing, ephemeral events\n ├── Presence: online users, status\n └── Postgres Changes: document content, game state\n ↑\n[Vercel Edge] → [Supabase Edge Functions] → [Postgres]\n Validation, auth, business logic\n```\n\nThe mental model: Broadcast is a WebSocket relay, Presence is a distributed hash table, and Postgres Changes is a push notification system for your database. Layer them together and you get a surprisingly capable real-time platform without managing any infrastructure.\n\nAfter six months in production with ~300 daily active users, the collaboration features have been solid. We've had two Supabase Realtime outages (both under 5 minutes), zero data loss (because the persistent state is in Postgres, not in the WebSocket layer), and our Supabase bill is $25/month for the Pro tier. The equivalent custom infrastructure, a WebSocket server, Redis for presence, a change stream consumer, would cost 10x more in both money and maintenance time."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#questions","content":""},{"heading":"How many concurrent WebSocket connections can Supabase Realtime handle?","level":3,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#question-1","content":"The managed Supabase platform handles up to 500 concurrent connections on the Free tier, 10,000 on Pro, and 200,000+ on Enterprise with horizontal scaling. Self-hosted, a single Realtime node handles ~50,000 connections with default settings on a 4-core machine. The bottleneck is usually Postgres CDC throughput, not WebSocket connections."},{"heading":"Is Supabase Realtime reliable enough for production multiplayer?","level":3,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#question-2","content":"For casual multiplayer (collaborative editing, presence, turn-based games), yes. For competitive real-time games requiring \\<50ms server-side tick rates, no, use dedicated game servers. Supabase Realtime adds 20-80ms of latency depending on the feature (Broadcast is fastest, Postgres Changes is slowest)."},{"heading":"What's the difference between Broadcast and Postgres Changes?","level":3,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#question-3","content":"Broadcast sends messages directly between clients through the Realtime server, no database involved, sub-20ms latency, but messages are ephemeral (lost if no one is listening). Postgres Changes listens to the WAL (Write-Ahead Log) and pushes database changes to subscribed clients, 30-80ms latency, but changes are persisted and you get the full transactional guarantees of Postgres."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#article-tips","content":"1. **LuchoTip:** Use ` Broadcast ` for ephemeral events (cursor moves, typing indicators) and ` Postgres Changes ` for persistent state. I see developers routing everything through Postgres CDC when half their events don't need persistence, it adds 15-30ms latency and unnecessary WAL load.\n2. **LuchoTip:** Set ` presenceKey ` to a stable user identifier instead of letting Supabase generate one. Default presence keys change on reconnect, so every network hiccup looks like a user leaving and rejoining. Use ` channel.track({ userId: user.id }) ` and your presence list stays stable.\n3. **LuchoTip:** When listening to Postgres Changes, always filter with ` .eq('column', 'value') ` in the channel subscription. Without filters, you receive ALL changes to the table across ALL users. I once shipped a collaborative editor where every user received every other user's document changes. The Supabase bill that month was educational.\n4. **LuchoTip:** Implement exponential backoff on your Realtime reconnection logic. Supabase's default client reconnects immediately, which during a regional outage means thousands of clients hammering the Realtime cluster simultaneously. Add jitter: ` Math.min(1000 * 2^attempt + Math.random() * 1000, 30000) `.\n\n- supabase\n- realtime\n- websockets\n- postgres\n- presence\n- multiplayer\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Lucio Durán","url":"https://lucioduran.com/"},{"text":"Español","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer"},{"text":"Supabase","url":"https://supabase.com/"},{"text":"Vercel","url":"https://vercel.com/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com/"},{"@type":"WebSite","@id":"https://lucioduran.com/#website","url":"https://lucioduran.com/","name":"Lucio Durán","inLanguage":["en","es"],"publisher":{"@id":"https://lucioduran.com/#person"}},{"@type":"WebPage","@id":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#page","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer","name":"Supabase Realtime: Building Multiplayer Features Without Running a Single WebSocket Server | Lucio Durán","description":"Presence tracking, broadcast channels, Postgres CDC, and scaling WebSocket connections, a practical guide to building multiplayer features with Supabase Realtime.","inLanguage":"en","author":{"@id":"https://lucioduran.com/#person"},"publisher":{"@id":"https://lucioduran.com/#person"},"isPartOf":{"@id":"https://lucioduran.com/#website"},"isAccessibleForFree":true,"datePublished":"2026-02-19","mainEntity":{"@id":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#article"},"breadcrumb":{"@id":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#breadcrumb"}},{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#article","mainEntityOfPage":{"@id":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#page"},"headline":"Supabase Realtime: Building Multiplayer Features Without Running a Single WebSocket Server","description":"Presence tracking, broadcast channels, Postgres CDC, and scaling WebSocket connections, a practical guide to building multiplayer features with Supabase Realtime.","datePublished":"2026-02-19","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@id":"https://lucioduran.com/#person"},"url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Backend","keywords":"supabase, realtime, websockets, postgres, presence, multiplayer","wordCount":1521,"timeRequired":"PT8M","workTranslation":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer#article","inLanguage":"es","url":"https://lucioduran.com/es/blog/supabase-realtime-presence-multiplayer"}],"isAccessibleForFree":true},{"@type":"Blog","@id":"https://lucioduran.com/blog#blog","url":"https://lucioduran.com/blog","name":"Blog","inLanguage":"en","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com/"},"publisher":{"@id":"https://lucioduran.com/#person"},"isPartOf":{"@id":"https://lucioduran.com/#website"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Supabase Realtime: Building Multiplayer Features Without Running a Single WebSocket Server","item":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer"}],"@id":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#breadcrumb"},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How many concurrent WebSocket connections can Supabase Realtime handle?","acceptedAnswer":{"@type":"Answer","text":"The managed Supabase platform handles up to 500 concurrent connections on the Free tier, 10,000 on Pro, and 200,000+ on Enterprise with horizontal scaling. Self-hosted, a single Realtime node handles ~50,000 connections with default settings on a 4-core machine. The bottleneck is usually Postgres CDC throughput, not WebSocket connections."}},{"@type":"Question","name":"Is Supabase Realtime reliable enough for production multiplayer?","acceptedAnswer":{"@type":"Answer","text":"For casual multiplayer (collaborative editing, presence, turn-based games), yes. For competitive real-time games requiring <50ms server-side tick rates, no, use dedicated game servers. Supabase Realtime adds 20-80ms of latency depending on the feature (Broadcast is fastest, Postgres Changes is slowest)."}},{"@type":"Question","name":"What's the difference between Broadcast and Postgres Changes?","acceptedAnswer":{"@type":"Answer","text":"Broadcast sends messages directly between clients through the Realtime server, no database involved, sub-20ms latency, but messages are ephemeral (lost if no one is listening). Postgres Changes listens to the WAL (Write-Ahead Log) and pushes database changes to subscribed clients, 30-80ms latency, but changes are persisted and you get the full transactional guarantees of Postgres."}}],"@id":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#questions","url":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#questions","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer#page"},"author":{"@id":"https://lucioduran.com/#person"}}]}],"contentHash":"3ee83dfe30f968b95340c0f703610d76ea4030b505c5827a0af1740aad59acf0","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/supabase-realtime-presence-multiplayer","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":73923,"contentBytes":14963}}
