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

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.

Setting Presence

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

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}`)
})

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

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

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:

// 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

Typing Indicators

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

// 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)
  }
})

Actor Presence Structure

Each actor presence object contains:

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:

const room = client.setApp('agents').setRoom('workers')

room.setPresence({
  name: 'echo-runtime-a91f',
  role: 'agent',
  capabilities: ['chat_response', 'soil_analysis'],
})

An orchestrator then reads the live set to route work, with no registry of its own:

// 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:

QuestionUse
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:

room.setPresence({
  name: 'soil-sensor-14',
  role: 'device',
  capabilities: ['telemetry'],
  persistent: true,
  wake: {
    url: 'https://example.com/hooks/nolag-wake',
    timeoutMs: 10000,
  },
})

Lifecycle

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

statusMeaning
onlineSocket connected right now
offlineRegistered but disconnected. Still discoverable, still addressable
wakingA 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:

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.

{
  "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