---
title: Presence Tracking
description: Track presence in real-time with NoLag. Know who is online, handle join/leave events, discover backend workers and AI agents by capability, and keep scaled-to-zero actors addressable with Persistent Presence.
---

# Presence Tracking

Know who's online in real-time. Track presence per room and handle join/leave events.

## What is Presence?

Presence tracking allows you to see which actors (users, devices, services, or AI
agents) are currently connected to a room. It covers two distinct jobs.

**For people:**

- Online/offline indicators in chat apps
- Showing who's viewing a document
- Live user counts
- Typing indicators

**For backend workers, devices, and AI agents:**

- Service discovery: which workers are up, and what each can do
- Routing work to an actor that advertises the right capability
- Detecting a worker that dropped, without polling or a `last_seen` table
- Keeping a scaled-to-zero service or sleeping device addressable, and waking it
  on demand, with [Persistent Presence](#persistent-presence)

**Room-scoped.** Presence is set on a room: `room.setPresence(data)`. The broker only tracks and broadcasts presence that names a room. The older client-level `client.setPresence()` without a room is deprecated: the broker accepts the frame but never broadcasts it, and `client.fetchPresence()` without a room always returns an empty list. Presence *events* are delivered on the client, `client.on('presence:join', ...)`, because `room.on()` is for topic messages and `room.on('presence:join', ...)` never fires. To observe presence across many rooms at once, use [Lobbies](/docs/concepts/lobbies).

## Setting Presence

Set your presence on a room after connecting. Presence data can include any custom fields you need:

```typescript [TypeScript]
import { NoLag } from '@nolag/js-sdk'

const client = NoLag('your_access_token')
await client.connect()

const room = client.setApp(APP_SLUG).setRoom('general')

// Set presence in the room
room.setPresence({
  username: 'Alice',
  status: 'online',
  avatar: 'https://example.com/alice.jpg'
})

// Presence events are delivered on the client
client.on('presence:join', (actor) => {
  console.log(`${actor.presence.username} joined`)
})

client.on('presence:leave', (actor) => {
  console.log(`${actor.actorTokenId} left`)
})

client.on('presence:update', (actor) => {
  console.log(`${actor.presence.username} updated status to ${actor.presence.status}`)
})
```
```python [Python]
from nolag import NoLag

client = NoLag('your_access_token')
await client.connect()

room = client.set_app(APP_SLUG).set_room('general')

# Set presence in the room
await room.set_presence({
    'username': 'Alice',
    'status': 'online',
    'avatar': 'https://example.com/alice.jpg'
})

# Presence events are delivered on the client
def on_join(actor):
    print(f"{actor.presence['username']} joined")

def on_leave(actor):
    print(f"{actor.actor_token_id} left")

def on_update(actor):
    print(f"{actor.presence['username']} updated status")

client.on('presence:join', on_join)
client.on('presence:leave', on_leave)
client.on('presence:update', on_update)
```
```go [Go]
package main

import (
    "fmt"
    "log"

    nolag "github.com/NoLagApp/go-sdk"
)

func main() {
    client := nolag.New("your_access_token")
    if err := client.Connect(); err != nil {
        log.Fatal(err)
    }

    room := client.SetApp(appSlug).SetRoom("general")

    // Set presence in the room
    room.SetPresence(map[string]any{
        "username": "Alice",
        "status":   "online",
        "avatar":   "https://example.com/alice.jpg",
    })

    // Presence events are delivered on the client, one nolag.ActorPresence per event
    client.On("presence:join", func(args ...any) {
        actor := args[0].(nolag.ActorPresence)
        fmt.Printf("%v joined\n", actor.Presence["username"])
    })

    client.On("presence:leave", func(args ...any) {
        actor := args[0].(nolag.ActorPresence)
        fmt.Printf("%s left\n", actor.ActorTokenID)
    })

    client.On("presence:update", func(args ...any) {
        actor := args[0].(nolag.ActorPresence)
        fmt.Printf("%v updated status to %v\n", actor.Presence["username"], actor.Presence["status"])
    })
}
```

## Presence Events

Listen for these events to track when actors come online, go offline, or update their status:

- `presence:join` - An actor set presence in the room for the first time
- `presence:leave` - An actor's socket dropped, or it set presence in a different room
- `presence:update` - An actor updated its presence data

Each event carries the actor's `actorTokenId` and its `presence` data. The `leave` event carries the id only.

### Who receives them

A room's presence events are broadcast to the actors that have set presence in that room. Subscribing to the room's topics does not enrol you: a client that only wants to watch must either set its own presence in the room, or observe the room through a [lobby](/docs/concepts/lobbies). One connection holds presence in one room at a time; setting presence in a second room leaves the first, and the first room's members receive `presence:leave`.

## Getting Current Presence

Read the local cache, or ask the server for the room's current list:

```typescript [TypeScript]
import type { ActorPresence } from '@nolag/js-sdk'

// Local cache, kept up to date by presence events.
// room.getPresence() returns a Record keyed by actorTokenId, not an array.
const cached: Record<string, ActorPresence> = room.getPresence()
console.log('Users online:', Object.keys(cached).length)

for (const actor of Object.values(cached)) {
  console.log(`- ${actor.presence.username} (${actor.actorTokenId})`)
}

// Fresh list from the server (also refreshes the cache)
const fresh = await room.fetchPresence()
console.log('Server says online:', fresh.length)
```
```python [Python]
# Local cache, kept up to date by presence events
actors = client.get_all_presence()
print('Users online:', len(actors))

for actor in actors:
    print(f"- {actor.presence['username']} ({actor.actor_token_id})")

# One actor from the cache
alice = client.get_presence('actor_token_id_here')
if alice:
    print(f"Alice is {alice.presence['status']}")

# Fresh list from the server: raw entries with actorTokenId, presence and joinedAt
fresh = await room.fetch_presence()
for entry in fresh:
    print(f"- {entry['presence'].get('username')} ({entry['actorTokenId']})")
```
```go [Go]
// Fresh list from the server for a room, by room slug
actors, err := client.GetPresence("general")
if err == nil {
    fmt.Println("Users online:", len(actors))

    for _, actor := range actors {
        fmt.Printf("- %v (%s)\n", actor.Presence["username"], actor.ActorTokenID)
    }
}
```

### Local Cache vs Server Fetch

- **JavaScript:** `room.getPresence()` returns the cache as a `Record<string, ActorPresence>`, `client.getPresence(actorId)` returns one cached actor, `room.fetchPresence()` fetches the room's list from the server
- **Python:** `client.get_all_presence()` returns the cache, `client.get_presence(actor_token_id)` returns one cached actor, `room.fetch_presence()` fetches the room's list from the server as plain dicts
- **Go:** `client.GetPresence("room-slug")` fetches the room's list from the server; there is no local cache

The local cache is updated by presence events, so it only reflects rooms whose events you receive.

## Updating Presence

Update your presence data at any time by calling `setPresence()` on the room again:

```typescript [TypeScript]
// Update your presence data (e.g., status change)
room.setPresence({
  username: 'Alice',
  status: 'away',
  lastActive: Date.now()
})

// Room presence is not re-sent automatically after a reconnect.
// 'connect' fires on every successful connection, so re-set it there.
client.on('connect', () => {
  room.setPresence({ username: 'Alice', status: 'online' })
})
```
```python [Python]
# Update your presence data (e.g., status change)
await room.set_presence({
    'username': 'Alice',
    'status': 'away',
    'lastActive': time.time()
})

# The Python SDK re-sends room presence after a reconnect
```
```go [Go]
// Update your presence data (e.g., status change)
room.SetPresence(map[string]any{
    "username":   "Alice",
    "status":     "away",
    "lastActive": time.Now().Unix(),
})

// Room presence is not re-sent automatically after a reconnect.
// "connected" fires on every successful connection, so re-set it there.
client.On("connected", func(args ...any) {
    room.SetPresence(map[string]any{"username": "Alice", "status": "online"})
})
```

## Typing Indicators

A common use case is showing typing indicators. Use presence updates with a debounce:

```typescript [TypeScript]
// For typing indicators, update presence with typing status
let typingTimeout: ReturnType<typeof setTimeout> | undefined

function sendTyping() {
  room.setPresence({
    username: 'Alice',
    status: 'online',
    isTyping: true
  })

  // Clear typing after 3 seconds of inactivity
  clearTimeout(typingTimeout)
  typingTimeout = setTimeout(() => {
    room.setPresence({
      username: 'Alice',
      status: 'online',
      isTyping: false
    })
  }, 3000)
}

// Listen for typing from others (client-level event)
client.on('presence:update', (actor) => {
  if (actor.presence.isTyping) {
    showTypingIndicator(actor.presence.username)
  } else {
    hideTypingIndicator(actor.presence.username)
  }
})
```
```python [Python]
# For typing indicators, update presence with typing status
typing_task = None

async def send_typing():
    global typing_task

    await room.set_presence({
        'username': 'Alice',
        'status': 'online',
        'isTyping': True
    })

    # Clear typing after 3 seconds of inactivity
    if typing_task:
        typing_task.cancel()

    async def clear_typing():
        await asyncio.sleep(3)
        await room.set_presence({
            'username': 'Alice',
            'status': 'online',
            'isTyping': False
        })

    typing_task = asyncio.create_task(clear_typing())

# Listen for typing from others (client-level event)
def on_presence_update(actor):
    if actor.presence.get('isTyping'):
        show_typing_indicator(actor.presence['username'])
    else:
        hide_typing_indicator(actor.presence['username'])

client.on('presence:update', on_presence_update)
```
```go [Go]
// For typing indicators, update presence with typing status
var typingTimer *time.Timer

func sendTyping() {
    room.SetPresence(map[string]any{
        "username": "Alice",
        "status":   "online",
        "isTyping": true,
    })

    // Clear typing after 3 seconds of inactivity
    if typingTimer != nil {
        typingTimer.Stop()
    }
    typingTimer = time.AfterFunc(3*time.Second, func() {
        room.SetPresence(map[string]any{
            "username": "Alice",
            "status":   "online",
            "isTyping": false,
        })
    })
}

// Listen for typing from others (client-level event)
func watchTyping() {
    client.On("presence:update", func(args ...any) {
        actor := args[0].(nolag.ActorPresence)
        username, _ := actor.Presence["username"].(string)
        if isTyping, ok := actor.Presence["isTyping"].(bool); ok && isTyping {
            showTypingIndicator(username)
        } else {
            hideTypingIndicator(username)
        }
    })
}
```

## Actor Presence Structure

Each actor presence object contains:

```typescript [TypeScript]
interface ActorPresence {
  actorTokenId: string      // Unique actor identifier
  presence: PresenceData    // Your custom fields (plus persistent and wake, below)
  joinedAt?: number         // Only present in fetchPresence() results
  status?: 'online' | 'offline' | 'waking'  // Persistent Presence only, absent otherwise
  actorType?: ActorType     // Optional; presence events and lists do not carry it
}
```

Presence events and fetch results carry `actorTokenId` and `presence`; fetch results add `joinedAt`. Python's `ActorPresence` has the same fields as `actor_token_id`, `presence`, `joined_at` and `status` (its `actor_type` defaults to `device`, the broker does not send one). Go's `nolag.ActorPresence` populates `ActorTokenID`, `Presence` and `Status`.

## Backend Workers and Agents

Presence is not only a chat feature. It is also how a backend service, worker
pool, or AI agent announces that it is running and what it can do, and how an
orchestrator discovers it.

**Do not mirror presence into your own database.** A `last_seen_at` column plus a
periodic "anything older than N minutes is dead" sweep is the usual way this gets
rebuilt, and it is strictly worse: the broker already knows the moment a socket
drops and emits `presence:leave` in realtime, where a staleness sweep is only as
fresh as its interval. Store durable records if you need history or audit, but
read liveness from presence.

A worker advertises itself the same way a user does, with the payload describing
capability rather than identity:

```typescript [TypeScript]
const room = client.setApp(APP_SLUG).setRoom('workers')

room.setPresence({
  name: 'echo-runtime-a91f',
  role: 'agent',
  capabilities: ['chat_response', 'soil_analysis'],
})
```
```python [Python]
room = client.set_app(APP_SLUG).set_room('workers')

await room.set_presence({
    'name': 'echo-runtime-a91f',
    'role': 'agent',
    'capabilities': ['chat_response', 'soil_analysis'],
})
```
```go [Go]
room := client.SetApp(appSlug).SetRoom("workers")

room.SetPresence(map[string]any{
    "name":         "echo-runtime-a91f",
    "role":         "agent",
    "capabilities": []string{"chat_response", "soil_analysis"},
})
```

An orchestrator then reads the live set to route work, with no registry of its own. It has set its own presence in the room, so it receives the room's events and its cache stays current:

```typescript [TypeScript]
import type { ActorPresence } from '@nolag/js-sdk'

// room.getPresence() returns a Record keyed by actorTokenId, not an array
const workers: Record<string, ActorPresence> = room.getPresence()
const available = Object.values(workers)
  .filter((a) => (a.presence.capabilities as string[])?.includes('chat_response'))
```

### Connection liveness is not task liveness

These are different questions and presence only answers the first:

| Question | Use |
|---|---|
| Is this worker connected? | Presence: `presence:join` / `presence:leave` |
| Is this worker still making progress on task X? | An application-level progress signal |

A worker can hold a healthy socket while a task is wedged, so presence will keep
reporting it online. If you need to fail a stalled task, publish progress events
from the worker and run a watchdog that resets on each one. That is a legitimate
application concern, not a gap in presence, and the two mechanisms belong side by
side.

## Persistent Presence

By default a presence record is ephemeral: it exists while the socket is
connected and disappears on disconnect. That does not suit a service that scales
to zero, a device that sleeps, or an agent you want to remain discoverable and
addressable while it is not running.

Persistent Presence keeps the record after the socket drops, so the actor stays
discoverable, and optionally lets NoLag wake it when work arrives.

Persistent Presence is available on NoLag cloud. A self-hosted or standalone
broker runs with the durable presence store and wake dispatcher disabled, where
`persistent` and `wake` are accepted and ignored, so the same code stays portable
and simply behaves as ephemeral presence.

### Advertising a persistent actor

Add `persistent` and, if the actor can be woken, a `wake` block:

```typescript [TypeScript]
room.setPresence({
  name: 'soil-sensor-14',
  role: 'device',
  capabilities: ['telemetry'],
  persistent: true,
  wake: {
    url: 'https://example.com/hooks/nolag-wake',
    timeoutMs: 10000,
  },
})
```
```python [Python]
await room.set_presence({
    'name': 'soil-sensor-14',
    'role': 'device',
    'capabilities': ['telemetry'],
    'persistent': True,
    'wake': {
        'url': 'https://example.com/hooks/nolag-wake',
        'timeoutMs': 10000,
    },
})
```
```go [Go]
room.SetPresence(map[string]any{
    "name":         "soil-sensor-14",
    "role":         "device",
    "capabilities": []string{"telemetry"},
    "persistent":   true,
    "wake": map[string]any{
        "url":       "https://example.com/hooks/nolag-wake",
        "timeoutMs": 10000,
    },
})
```

### Lifecycle

A persistent record carries a `status` that an ephemeral one does not:

| `status` | Meaning |
|---|---|
| `online` | Socket connected right now |
| `offline` | Registered but disconnected. Still discoverable, still addressable |
| `waking` | A wake webhook has been fired and NoLag is waiting for the reconnect |

```
   advertise(persistent)          socket drops
 ─────────────────────▶ online ─────────────────▶ offline
                          ▲                          │
                          │                          │ message routed to it
       reconnects, queued │                          ▼
       messages flush     └───────────────────── waking
```

Discovery returns offline actors too, so filter on status when you only want
what is live right now:

```typescript [TypeScript]
import type { ActorPresence } from '@nolag/js-sdk'

const workers: Record<string, ActorPresence> = room.getPresence()
const live = Object.values(workers).filter(
  (a) => a.status === undefined || a.status === 'online',
)
```

`status` is absent for ordinary ephemeral actors, which is why the check above
treats `undefined` as live.

### Wake webhook

When a message is routed to an `offline` persistent actor, NoLag queues the
message on the actor's session and POSTs to the registered `wake.url` so the
actor can cold-start and drain it. The webhook carries no message payload.

```json [POST body]
{
  "appId": "019f...",
  "roomId": "019f...",
  "actorTokenId": "019f...",
  "reason": "dispatch",
  "wakeId": "a91f2c...",
  "ts": 1765200000000
}
```

Verify the signature before acting on it:

```
x-nolag-signature: sha256=<hex HMAC-SHA256 of the raw body>
```

- Wakes are **debounced**: one per actor per `waking` window, not one per message.
- The call is fire-and-forget and never blocks the publisher.
- `timeoutMs` defaults to `10000`.
- Treat repeated `wakeId` values as no-ops; a cold start may already be in flight.
- Respond `2xx` to acknowledge. NoLag then waits for the actor to reconnect, not
  for the work to finish.

A failed wake is logged and dropped, not retried. If your endpoint is down when
the wake fires, the queued message waits for the actor to reconnect on its own.
Treat the wake as a best-effort nudge rather than a delivery guarantee.

## Best Practices

- **Keep presence data small** - Only include necessary information (username, status, avatar URL)
- **Use debouncing** - For typing indicators, debounce updates to avoid flooding
- **Handle reconnections** - Re-set room presence from your `connect` handler in JavaScript and Go; the Python SDK re-sends it for you
- **Consider privacy** - Let users opt out of presence tracking if needed
- **Use fetchPresence() sparingly** - The local cache is usually sufficient once you have set presence in the room

## Next Steps

- [Observe presence across rooms with Lobbies](/docs/concepts/lobbies)
- [Learn about Topics](/docs/concepts/topics)
- [Organize with Rooms](/docs/concepts/rooms)
- [Build a Chat App](/docs/guides/chat-app)
