Skip to content

WebSocket Presence

Presence answers a simple question: who is online right now, and where? Enable it on a WebSocket endpoint and Slingshot tracks which user IDs hold open sockets in each room — the backbone of “online users” indicators, lobby lists, and “X is typing” widgets.

Set presence: true on a WsEndpointConfig and the framework attaches per-socket bookkeeping to the WebSocket state. When a socket subscribes to a room the user is added to that room’s presence set; when the last socket for that user leaves, the user is removed. Multiple sockets per user (multi-tab, mobile + desktop) collapse to a single presence entry — only the first connection and last disconnection produce events.

Reads are O(1). getRoomPresence(state, endpoint, room) returns the user IDs currently in a room; getUserPresence(state, userId) returns every room a user is in across endpoints.

app.config.ts
import { defineApp } from '@lastshotlabs/slingshot';
export default defineApp({
ws: {
endpoints: {
'/ws/chat': {
presence: { broadcastEvents: true },
incoming: {
join: {
handler: (ws, payload, ctx) => {
const room = (payload as { room: string }).room;
ctx.subscribe(room);
},
},
},
},
},
},
});

presence: true is the shorthand for presence: { broadcastEvents: false }. Setting broadcastEvents: true makes the framework publish presence:join and presence:leave events to the room when users come and go — useful when every connected client needs to react to the change.

src/chat/package.ts
// @skip-typecheck
import { definePackage, domain, getContext, getRoomPresence, route } from '@lastshotlabs/slingshot';
export const chatPackage = definePackage({
name: 'chat',
domains: [
domain({
name: 'rooms',
basePath: '/rooms',
routes: [
route.get({
path: '/:room/presence',
handler: async ({ c, respond }) => {
const ctx = getContext(c.get('app'));
const users = getRoomPresence(ctx.wsState, '/ws/chat', c.req.param('room'));
return respond.json({ users, count: users.length });
},
}),
],
}),
],
});

That gives you a plain HTTP endpoint clients can poll on connect to render the initial list before subscribing to live updates.

Per-instance presence is local. In a multi-instance deployment one server only sees its own sockets — instance A does not know about instance B’s connected users. Cross-instance fanout runs through the WebSocket transport (Redis pub/sub by default), so when instance A broadcasts a presence:join to a room, instance B’s connected clients in that room receive it.

The presence registry itself is not replicated. If you need a globally-correct online list, fold the join/leave events into a shared store (Redis SET keyed by room) inside an onRoomSubscribe hook.