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_seentable - Keeping a scaled-to-zero service or sleeping device addressable, and waking it on demand, with 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.
Setting Presence
Set your presence on a room 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()
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}`)
})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 timepresence:leave- An actor's socket dropped, or it set presence in a different roompresence: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. 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:
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)Local Cache vs Server Fetch
- JavaScript:
room.getPresence()returns the cache as aRecord<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:
// 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' })
})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: 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)
}
})Actor Presence Structure
Each actor presence object contains:
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:
const room = client.setApp(APP_SLUG).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. It has set its own presence in the room, so it receives the room's events and its cache stays current:
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:
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:
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:
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.
{
"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
wakingwindow, not one per message. - The call is fire-and-forget and never blocks the publisher.
timeoutMsdefaults to10000.- Treat repeated
wakeIdvalues as no-ops; a cold start may already be in flight. - Respond
2xxto 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
connecthandler 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