Presence
Track who is online with Supabase Realtime Presence on SupaHost. Share a small state per user, react to join and leave events, and keep presence private with authorization.
Presence keeps a shared, up-to-date list of the clients on a channel. Each client publishes a small state object ("I'm Ana, I'm viewing invoice 1042, I'm idle"), and every other client on the channel gets the combined list, plus events when someone joins or leaves. Realtime handles the bookkeeping: when a client disconnects, its state is removed for everyone.
Use Presence for online indicators, avatars on a shared document, "2 people are editing" notices, or picking a leader among connected clients. For frequent updates like cursor positions, use Broadcast instead; Presence is meant for state that changes now and then.
Track your own state
Join a channel, wait for SUBSCRIBED, then call track() with your state. In this example, an invoicing app shows who else has an invoice open.
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!)
const { data: { user } } = await supabase.auth.getUser()
const viewers = supabase.channel('invoice:1042', {
config: {
presence: { key: user!.id }, // group presences by user id
},
})
viewers
.on('presence', { event: 'sync' }, () => {
const state = viewers.presenceState()
renderViewers(state)
})
.on('presence', { event: 'join' }, ({ key, newPresences }) => {
console.log(`${newPresences[0].name} opened the invoice`)
})
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
console.log(`${leftPresences[0].name} left`)
})
.subscribe(async (status) => {
if (status !== 'SUBSCRIBED') return
await viewers.track({
name: 'Ana Lima',
avatarUrl: 'https://app-one.acme.supahost.space/storage/v1/object/public/avatars/ana.webp',
openedAt: new Date().toISOString(),
})
})
Reading the state
presenceState() returns an object keyed by presence key. Each value is an array, because the same user can be connected from several tabs or devices:
{
"8f9c2d1e-...": [
{ "name": "Ana Lima", "openedAt": "2026-11-11T09:14:00Z", "presence_ref": "F1a..." },
{ "name": "Ana Lima", "openedAt": "2026-11-11T09:20:31Z", "presence_ref": "F1b..." }
],
"3b2a7c90-...": [
{ "name": "Sam Okafor", "openedAt": "2026-11-11T09:16:12Z", "presence_ref": "F1c..." }
]
}
To show one avatar per person, use the keys:
function renderViewers(state: Record<string, Array<{ name: string; avatarUrl?: string }>>) {
const people = Object.values(state).map((sessions) => sessions[0])
setViewers(people) // your UI state
}
If you don't set presence.key, Realtime generates a random key per connection, so each tab appears as a separate entry.
The three events
| Event | When it fires | Use it for |
|---|---|---|
sync |
After any change, once the client's copy of the state is up to date | Re-rendering the full list. The simplest and safest choice |
join |
When one or more presences are added | Toasts like "Sam joined" |
leave |
When presences are removed (untrack or disconnect) | Toasts like "Sam left" |
sync is all most apps need. join and leave also fire for state updates, because updating your state is a leave of the old state and a join of the new one.
Updating and removing your state
Call track() again with new values to update your state. Call untrack() to stop appearing on the channel without leaving it:
// The user switched to another browser tab
document.addEventListener('visibilitychange', async () => {
if (document.hidden) {
await viewers.track({ name: 'Ana Lima', status: 'away' })
} else {
await viewers.track({ name: 'Ana Lima', status: 'active' })
}
})
// The user closed the invoice
await viewers.untrack()
await supabase.removeChannel(viewers)
You don't have to untrack before closing the page. When the WebSocket drops, Realtime removes that connection's presence after it notices the disconnect. On mobile, an app in the background may be disconnected by the operating system, which makes the user appear to leave. See Silent disconnects when the app is in the background.
Keep it small
Every presence change is sent to every client on the channel, and each one counts toward your project's Realtime rate limits. Practical guidance:
- Track identity and coarse state only (name, avatar, status). Load profiles and details from your tables.
- Don't call
track()in a tight loop. Throttle updates to a few per minute per client. - Split large audiences into smaller channels, such as one per document, not one for the whole app.
See Realtime capacity and limits.
Private presence
On a public channel, anyone with your anon key who knows the topic can read the presence list and add themselves to it. To restrict that, create the channel with private: true and add RLS policies on realtime.messages where extension = 'presence'. A select policy controls who can see the list; an insert policy controls who can track.
await supabase.realtime.setAuth()
const viewers = supabase.channel('invoice:1042', {
config: { private: true, presence: { key: user!.id } },
})
The policies are covered in Realtime authorization.
Presence is not your source of truth
Presence reflects connections, not facts. A user can appear online for a short time after losing connection, or briefly disappear during a reconnect. Use it for display. For anything that must be exact, like who holds a lock on a record, store it in a table with a timestamp and expire it in SQL.