Add realtime updates
Your UI shows stale data until someone hits refresh. Realtime updates push changes — new chat messages, order status, live dashboards, presence and typing indicators — to every connected client the instant they happen, so no one has to reload.
Most "realtime" is really "when the database changes, the screen should change." Don't hand-roll a WebSocket server — pick a managed realtime layer and match it to the job: a reactive/synced database (Convex, or Supabase Realtime on Postgres) for data you already store, pub/sub broadcast channels (Ably, Pusher, Liveblocks) for ephemeral events like cursors and typing indicators, and Server-Sent Events for one-way streams like AI tokens. Whatever you pick, the pattern is the same: load current state first, then subscribe, authorize the subscription, and clean up on unmount.
Step by step
- 1
Classify what you actually need
Sort your feature into one of three buckets: database sync (rows change, UI updates — chat, dashboards, order status), ephemeral pub/sub (typing indicators, cursors, presence that you don't persist), or one-way streaming (AI tokens, notifications). This single choice picks your tool and saves you from over-engineering.
- 2
Use a managed provider, not a raw WebSocket server
Serverless hosts like Vercel and Netlify can't hold long-lived sockets — functions time out. Reach for Supabase Realtime or Convex for DB sync, Ably or Pusher for pub/sub. They handle auth, reconnection, and fanout so you don't reinvent them badly.
- 3
Load first, then subscribe
Fetch the current rows, THEN open the subscription. If you subscribe first (or only subscribe), any event that fires during page load is dropped and the list is permanently missing data. Do both, in that order, inside the same effect.
- 4
Authorize every subscription
Realtime bypasses your REST auth. On Supabase, enable Row Level Security so a client only receives rows it's allowed to see; on Pusher/Ably use private, server-authenticated channels. An unauthenticated subscription is a data leak that no amount of frontend code hides.
- 5
Clean up and survive reconnects
Unsubscribe on unmount (return a cleanup function that removes the channel), dedupe incoming events by id, and refetch state on reconnect so events missed while offline don't leave the UI stale. Leaked channels blow past connection limits and duplicate messages.
- 6
Add optimistic updates and dedupe the echo
Insert the user's own write into local state immediately, then dedupe when the realtime echo of that same row arrives (match on id). Skip this and every message the user sends shows up twice or flickers.
- 7
Verify with two clients
Open two browser windows (or one plus an incognito window signed in as another user). A change in one should appear in the other within a second — and the second user should NOT see data they aren't authorized for.
Starter snippet
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
export function useMessages(roomId: string) {
const [messages, setMessages] = useState<Message[]>([])
useEffect(() => {
let active = true
// 1. Load current state BEFORE subscribing (RLS decides what you get back)
supabase.from('messages').select('*').eq('room_id', roomId)
.order('created_at')
.then(({ data }) => { if (active && data) setMessages(data) })
// 2. Subscribe to future inserts on this room only
const channel = supabase
.channel(`room:${roomId}`)
.on('postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages',
filter: `room_id=eq.${roomId}` },
({ new: msg }) => setMessages((prev) =>
prev.some((m) => m.id === (msg as Message).id) // dedupe the echo
? prev : [...prev, msg as Message]))
.subscribe()
// 3. Always clean up
return () => { active = false; supabase.removeChannel(channel) }
}, [roomId])
return messages
}✕ Watch out for
- Rolling your own WebSocket server on serverless — Vercel/Netlify functions time out and can't hold connections. Use a managed provider or a dedicated socket host.
- Subscribing before (or instead of) loading initial data — events that fire between page load and subscription vanish silently, so the UI is permanently missing rows.
- No authorization on the channel — realtime bypasses your REST auth. Without Row Level Security or private channels, any client can listen to any user's data.
- Forgetting to unsubscribe — leaked channels stack up on every navigation, hit connection limits, and duplicate messages (React StrictMode double-mounts effects in dev and exposes this).
- Optimistic writes with no dedupe — your local insert plus the realtime echo of the same row renders every message twice.
✓ Pro tips
- Give your AI agent the exact provider AND pattern, not just 'make it realtime': e.g. 'Use Supabase Realtime postgres_changes — fetch initial rows, then subscribe with a room_id filter, then removeChannel in the effect cleanup.' Then verify it did BOTH the initial fetch and the subscription — agents routinely ship one without the other, giving you a list that never populates or one that never updates.
- Ask the agent point-blank: 'What stops another user from subscribing to this channel and reading data they shouldn't?' If the answer isn't RLS (Supabase) or an authenticated private channel (Pusher/Ably), it built a data leak — make it fix that before anything else.
- If you see duplicate messages in dev, your cleanup isn't removing the channel — React 18/19 StrictMode double-mounts effects on purpose to catch exactly this.
- Test on a throttled connection (devtools network tab), not just localhost. Realtime that feels instant locally falls apart on flaky mobile; confirm your reconnect logic refetches state instead of assuming no events were missed.