Presence

Know who is connected, in realtime. Presence is tracked per room, and every member of the room hears joins, leaves and updates as they happen.

What is Presence?

Presence tells you which actors (users, devices or services) are currently connected to a room, along with a small payload each one chooses to share. It covers two kinds of job.

For people:

  • Online and offline indicators in chat apps
  • Showing who is viewing a document
  • Live user counts
  • Typing indicators

For backend workers and devices:

  • Which workers are up, and what each one can do
  • Routing work to a connected worker that advertises the right capability
  • Noticing that a worker or device dropped, without polling or a last_seen table

Presence is tied to the live WebSocket connection. It exists while the socket is open and is removed the moment it closes. kraken keeps no presence record for an actor that is not connected.

Room-scoped. Presence is set on a room: room.setPresence(data). kraken only tracks and broadcasts presence that names a room. The older client-level client.setPresence() without a room is accepted but never broadcast, 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. To observe presence across many rooms at once, use Lobbies.

Setting Presence

Set your presence on a room after connecting. The payload can hold any fields you need:

import { NoLag } from "@nolag/js-sdk";

const client = NoLag("dev-token-alice", { url: "ws://localhost:8080/ws" });
const room = client.setApp("demo").setRoom("general");

// 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} is now ${actor.presence.status}`);
});

// Room presence is not re-sent after a reconnect, so set it here:
// 'connect' fires on every successful connection
client.on("connect", () => {
  room.setPresence({
    username: "Alice",
    status: "online",
    avatar: "https://example.com/alice.jpg",
  });
});

await client.connect();

Presence Events

  • presence:join: an actor set presence in the room for the first time on this connection
  • presence:leave: an actor's connection closed, or it set presence in a different room
  • presence:update: an actor set new presence data

Each event carries the actor's actorTokenId and its presence payload. A leave carries the id only. Events do not name the room; since a connection holds presence in one room at a time, they are for the room you set presence in. The first time an actor sets presence in a room, members receive a join followed by an update with the same data.

Who receives them

A room's presence events go to every connection that has set presence in that room, including your own: you receive your own join and updates. 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. To be present in several rooms at once, open a connection per room.

Getting Current Presence

Read the local cache, or ask kraken 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("Online:", Object.keys(cached).length);

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

// Fresh list from kraken (also replaces the cache)
const fresh = await room.fetchPresence();
console.log("kraken says online:", fresh.length);
  • JavaScript: room.getPresence() returns the cache, client.getPresence(actorId) returns one cached actor, room.fetchPresence() asks kraken and refreshes the cache
  • Python: client.get_all_presence() returns the cache, client.get_presence(actor_token_id) returns one cached actor, room.fetch_presence() asks kraken and returns plain dicts
  • Go: client.GetPresence("room-slug") asks kraken; there is no local cache

The cache is built from presence events, so it only reflects the room you have set presence in. kraken's list includes joinedAt, the unix time in seconds when the actor joined.

Updating Presence

Call setPresence() on the room again at any time. Members receive presence:update:

room.setPresence({
  username: "Alice",
  status: "away",
  lastActive: Date.now(),
});

Typing Indicators

A common use is a typing indicator: update presence when the user types, and clear it after a pause.

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

client.on("presence:update", (actor) => {
  if (actor.presence.isTyping) {
    showTypingIndicator(actor.presence.username);
  } else {
    hideTypingIndicator(actor.presence.username);
  }
});

You can also publish typing events on a dedicated topic such as _typing, which keeps them out of the presence payload.

Actor Presence Structure

interface ActorPresence {
  actorTokenId: string; // Unique actor identifier
  presence: PresenceData; // Your custom fields
  joinedAt?: number; // Only in fetchPresence() results, unix seconds
}

Python's ActorPresence has the same data as actor_token_id, presence and joined_at. Go's nolag.ActorPresence populates ActorTokenID and Presence.

Backend Workers and Devices

Presence is also how a backend worker or a device announces that it is running and what it can do. It advertises itself the same way a user does, with the payload describing capability rather than identity:

const workers = client.setApp("ops").setRoom("workers");

client.on("connect", () => {
  workers.setPresence({
    name: "resizer-a91f",
    role: "worker",
    capabilities: ["resize", "thumbnail"],
  });
});

A dispatcher that has set its own presence in the same room receives the room's events, so its cache stays current and it can route work without a registry of its own:

import type { ActorPresence } from "@nolag/js-sdk";

const present: Record<string, ActorPresence> = workers.getPresence();
const resizers = Object.values(present).filter((a) =>
  (a.presence.capabilities as string[] | undefined)?.includes("resize"),
);

Do not mirror presence into your own database. A last_seen_at column plus a periodic "older than N minutes is dead" sweep is the usual way this gets rebuilt, and it is strictly worse: kraken knows the moment a socket drops and emits presence:leave right away, 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.

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 stuck, so presence keeps 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.

How kraken Resolves the Room

The room in a presence update is a room slug. kraken looks it up in the actor's grants to find the room's id, and keys presence on that id:

  • With @nolag/core, every room the actor can reach carries an id, so presence works in all of them.
  • With the static file, presence only works in rooms that have a rule carrying both room_slug and room_id. A rule like games/+/# grants messages in every room but presence in none. Give each room_id a value that is unique across the whole deployment, because presence is grouped by it.
  • A presence update for a room kraken cannot resolve is ignored without an error. If nobody sees your presence, check the grant first.
  • The slug is matched without the app. If an actor can reach rooms with the same slug in two apps, kraken uses whichever it finds first.

Presence is not isolated by access scope. Presence is grouped by room id, and access scopes share an app's rooms, so actors in different scopes who set presence in the same room see each other's presence (topics stay isolated). Keep presence payloads free of anything one tenant must not see about another, or give tenants separate rooms.

Best Practices

  • Keep presence data small: every update goes to every member of the room
  • Debounce frequent updates: typing indicators and cursors especially
  • 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 if needed
  • Use fetchPresence() sparingly: the local cache is usually enough once you have set presence in the room

Next Steps