# 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.

Source: https://supahost.app/docs/realtime/presence · Updated 2026-11-11

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](/docs/realtime/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.

```ts
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:

```ts
{
  "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:

```ts
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:

```ts
// 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](/kb/realtime-background-disconnects).

## 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](/docs/realtime/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.

```ts
await supabase.realtime.setAuth()
const viewers = supabase.channel('invoice:1042', {
  config: { private: true, presence: { key: user!.id } },
})
```

The policies are covered in [Realtime authorization](/docs/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.

## Related

- [Broadcast](/docs/realtime/broadcast)
- [Understanding Realtime heartbeats](/kb/realtime-heartbeats)
- [Presence reference in Supabase's docs](https://supabase.com/docs/guides/realtime/presence)
