Presence

Track who’s connected to a topic in real time. Handles multi-tab dedup (same user with two tabs open = one presence entry), broadcasts join/leave events, and provides a live store on the client.

Setup

Create a shared presence instance:

// src/lib/server/presence.js
import { createPresence } from 'svelte-adapter-uws/plugins/presence';

export const presence = createPresence({
  key: 'id',
  select: (userData) => ({ id: userData.id, name: userData.name })
  // heartbeat defaults to 30000 ms; pass 0 to disable
});

Wire it into your WebSocket hooks:

// src/hooks.ws.js
import { presence } from '$lib/server/presence';

export function upgrade({ cookies }) {
  const user = validateSession(cookies.session_id);
  if (!user) return false;
  return { id: user.id, name: user.name };
}

export const { subscribe, unsubscribe, close } = presence.hooks;

The hooks object handles everything: subscribe calls join() for regular topics and sends the current presence snapshot for __presence:* topics, close calls leave(). If you need custom logic (auth gating, topic filtering), wrap the hook:

export function subscribe(ws, topic, ctx) {
  if (topic === 'vip' && !ws.getUserData().isVip) return false;
  presence.hooks.subscribe(ws, topic, ctx);
}

export const { unsubscribe, close } = presence.hooks;

Options

OptionDefaultDescription
key'id'Field used for multi-tab dedup
selectrecursive denylist(userData) -> publicData - extracts public fields. The default strips __-prefixed keys and known sensitive patterns (token, secret, password, auth, session, cookie, jwt, credential).
heartbeat30000Broadcast roster every N ms (carries {userKey: data} map so swept-out clients can re-add themselves). Pass 0 to disable.
maxConnections1_000_000Hard cap on tracked connections.
maxTopics1_000_000Hard cap on active topic registry.

Heartbeat defaults to on (30 s) since 0.5.3 so the new client-side maxAge: 90000 default works out of the box (still-present users refresh three times per sweep window).

Server API

MethodDescription
presence.hooksReady-made { subscribe, unsubscribe, close } hooks
presence.join(ws, topic, platform)Add user to topic (call from subscribe hook)
presence.leave(ws, platform)Remove from all topics (call from close hook)
presence.sync(ws, topic, platform)Send snapshot without joining (for observers)
presence.list(topic)Current user data array
presence.count(topic)Unique user count
presence.flushDiffs()Drain buffered diff publishes synchronously
presence.clear()Reset everything (stops heartbeat timer)

Client API

import { presence } from 'svelte-adapter-uws/plugins/presence/client';

const users = presence('room');
// $users = [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }]

The client store defaults to a 90 s maxAge sweep: entries that haven’t been refreshed by a heartbeat or diff / state inside the window are removed from the local map. With the server’s 30 s default heartbeat, still-present users are refreshed three times per window and never flicker; ghost entries left by silent server-side cleanup (cluster mass-disconnect, ungraceful client close) clear within one sweep window.

For admin / audit views that want unbounded retention (“show every user who ever touched this topic”), opt out with maxAge: 0:

const everyoneEver = presence('room', { maxAge: 0 });

To customize the window, set maxAge and the matching server heartbeat together (rule of thumb: heartbeat is one-third of maxAge or less, so a still-present user gets at least two refreshes per sweep window):

// Server: heartbeat every 60s
const presence = createPresence({ key: 'id', heartbeat: 60_000 });

// Client: entries expire after 180s without a refresh
const users = presence('room', { maxAge: 180_000 });

Reconnect snapshot

On every WebSocket open (initial connect AND every reconnect), the client sends a {type:'presence-snapshot', topic} text frame. The bundled Redis-backed presence in extensions handles this via presence.hooks.message and re-emits a state frame so a tab that missed diff frames during a network blip reconciles to current state.

The in-memory variant self-heals via the heartbeat path alone (the heartbeat carries a {userKey: data} map, so a swept-out entry can be re-added from any tick), so no message hook wiring is required for single-instance deployments. If you want explicit reconnect-snapshot behavior on single-instance, your message hook can call presence.sync(ws, topic, platform) directly when the frame arrives.

Client example

<!-- src/routes/room/+page.svelte -->
<script>
  import { on } from 'svelte-adapter-uws/client';
  import { presence } from 'svelte-adapter-uws/plugins/presence/client';

  const messages = on('room');
  const users = presence('room');
</script>

<aside>
  <h3>{$users.length} online</h3>
  {#each $users as user (user.id)}
    <span>{user.name}</span>
  {/each}
</aside>

SSR with presence

Use presence.list() in load functions:

// +page.server.js
import { presence } from '$lib/server/presence';

export async function load() {
  return { users: presence.list('room'), online: presence.count('room') };
}

How multi-tab dedup works

If user “Alice” (key id: '1') has three browser tabs open, presence.join() is called three times with the same key. The plugin ref-counts connections per key: Alice appears once in the list. When she closes two tabs, she stays present. Only when the last tab closes does the plugin broadcast a leave event.

If Alice’s data changes between connections (for example she updates her avatar in one session and opens a fresh tab), join() detects the difference and broadcasts a presence diff so other clients immediately see the new data.

If no key field is found in the selected data (e.g. no auth), each connection is tracked separately.

Wire format - state, diff, heartbeat

The wire shape on __presence:{topic} is two diff-shaped events plus the heartbeat:

EventWhenPayload
stateOnce per subscribe{[userKey]: data} flat snapshot. Sent to a single connection on join or sync.
diffTopic broadcasts, batched{joins: {[key]: data}, leaves: {[key]: data}}. Same-tick joins/leaves on the same key collapse to the latest op; updates appear as joins entries with the new data.
heartbeatServer timer{[userKey]: data} map. Refreshes existing entries AND re-adds any that were swept out by client maxAge.

Renamed from presence_state / presence_diff in adapter 0.5.7 to match the cursor / groups / replay convention of bare event names scoped to their plugin topic. The bundled presence() Svelte client store handles both events transparently. Hand-rolled wire decoders need to use the new state / diff names.

The diff buffer flushes via setTimeout(0) (adapter 0.5.8) so co-arriving joins / leaves from separate WebSocket message handlers coalesce into one diff frame. Use presence.flushDiffs() to drain the buffer synchronously in tests or shutdown paths.

Closed-WS safety

presence.join() throws WsClosedError (code: 'WS_CLOSED') if the WebSocket closes during an internal async gap (auth check, peer-instance probe, subscribe call). Catch shape:

try {
  await presence.join(ws, topic, platform);
} catch (err) {
  if (err.code !== 'WS_CLOSED') throw err;
  // client disconnected mid-join; state already rolled back, no further action needed
}

The throw is purely the observability signal - state rollback runs unconditionally. Prometheus counter: presence_joins_aborted_total{topic, reason="ws_closed"}.

Limitations

  • In-memory only. Same as replay - server restart clears presence. On restart, clients reconnect and re-subscribe, so the list rebuilds within seconds.
  • Single-worker only. Each worker tracks its own presence. In clustered mode, the list reflects only the local worker’s connections. For cluster-wide presence, use the Redis-backed variant from the extensions package.
  • Requires subscription. The client must subscribe to the topic (via on(), crud(), etc.) for the server’s subscribe hook to fire. presence('room') alone shows you the list but doesn’t register you as present unless you’re also subscribed to room.

Was this page helpful?