---
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 user presence across your project 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 your project. 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)

**Client-level:** Presence events (`presence:join`, `presence:leave`, `presence:update`) are tracked at the client level, not room-scoped. Use `client.on()` to listen for these events. For observing presence across rooms, use [Lobbies](/docs/concepts/lobbies).

## Setting Presence

Set your presence 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()

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

// Presence events are client-level, not room-scoped
client.on('presence:join', (actor) => {
  console.log(`${actor.presence.username} joined`)
})

client.on('presence:leave', (actor) => {
  console.log(`${actor.presence.username} 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()

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

# Presence events are client-level, not room-scoped
def on_join(actor):
    print(f"{actor.presence['username']} joined")

def on_leave(actor):
    print(f"{actor.presence['username']} 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"

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

func main() {
    client := nolag.New("your_access_token")
    client.Connect()

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

    // Listen for presence events (join, leave, update)
    client.On("presence", func(args ...any) {
        topic, _ := args[0].(string)
        data, _ := args[1].(map[string]any)
        fmt.Printf("Presence event on %s: %v\n", topic, data)
    })
}
```

## Presence Events

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

- `presence:join` - An actor connected and set their presence
- `presence:leave` - An actor disconnected
- `presence:update` - An actor updated their presence data

## Getting Current Presence

Get a list of all actors currently present. Note that `getPresence()` returns all actors from the local cache (not room-scoped):

```typescript [TypeScript]
// Get all present actors from local cache (client-level)
const actors = client.getPresence()
console.log('Users online:', actors.length)

actors.forEach((actor) => {
  console.log(`- ${actor.presence.username} (${actor.actorTokenId})`)
})

// Fetch fresh presence list from server
const freshList = await client.fetchPresence()
console.log('Server says online:', freshList.length)
```
```python [Python]
# Get all present actors from local cache (client-level)
actors = client.get_all_presence()
print('Users online:', len(actors))

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

# Get a specific actor's presence
alice = client.get_presence('actor_token_id_here')
if alice:
    print(f"Alice is {alice.presence['status']}")
```
```go [Go]
// Get presence for actors on a topic
room := client.SetApp("my-app").SetRoom("general")
actors, err := client.GetPresence(room.Prefix() + "/messages")
if err == nil {
    fmt.Println("Users online:", len(actors))

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

### Local Cache vs Server Fetch

- **JavaScript:** `client.getPresence()` returns all, `client.getPresence(actorId)` returns one, `client.fetchPresence()` fetches from server
- **Python:** `client.get_all_presence()` returns all, `client.get_presence(actor_token_id)` returns one
- **Go:** `client.GetPresence(topic)` fetches presence for a specific topic from the server

The local cache is automatically updated when you receive presence events.

## Updating Presence

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

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

// Client-level presence is automatically re-sent on reconnect
```
```python [Python]
# Update your presence data (e.g., status change)
await client.set_presence({
    'username': 'Alice',
    'status': 'away',
    'lastActive': time.time()
})

# Client-level presence is automatically re-sent on reconnect
```
```go [Go]
// Update your presence data (e.g., status change)
client.SetPresence(map[string]any{
    "username":   "Alice",
    "status":     "away",
    "lastActive": time.Now().Unix(),
})

// Client-level presence is automatically re-sent on reconnect
```

## 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: NodeJS.Timeout

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

  // Clear typing after 3 seconds of inactivity
  clearTimeout(typingTimeout)
  typingTimeout = setTimeout(() => {
    client.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 client.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 client.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() {
    client.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() {
        client.SetPresence(map[string]any{
            "username": "Alice",
            "status":   "online",
            "isTyping": false,
        })
    })
}

// Listen for presence events
client.On("presence", func(args ...any) {
    data, _ := args[1].(map[string]any)
    if isTyping, ok := data["isTyping"].(bool); ok && isTyping {
        showTypingIndicator(data["username"].(string))
    } else {
        hideTypingIndicator(data["username"].(string))
    }
})
```

## Actor Presence Structure

Each actor presence object contains:

```typescript [TypeScript]
interface ActorPresence {
  actorTokenId: string    // Unique actor identifier
  actorType: 'device' | 'user' | 'server'
  presence: {             // Your custom presence data
    username?: string
    status?: string
    // ... any other fields you set
  }
  joinedAt?: number       // Timestamp when actor connected
}
```

## 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('agents').setRoom('workers')

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

await room.set_presence({
    'name': 'echo-runtime-a91f',
    'role': 'agent',
    'capabilities': ['chat_response', 'soil_analysis'],
})
```
```go [Go]
room := client.SetApp("agents").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:

```typescript [TypeScript]
// Room.getPresence() returns a Record keyed by actorTokenId, not an array
const available = Object.values(room.getPresence())
  .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]
const live = Object.values(room.getPresence()).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** - Client-level presence is automatically re-sent on reconnect
- **Consider privacy** - Let users opt out of presence tracking if needed
- **Use fetchPresence() sparingly** - The local cache is usually sufficient

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