Presence
Same API as the core createPresence plugin, but backed by Redis hashes. Presence state is shared across instances with cross-instance join/leave notifications via Redis pub/sub.
When to use over the built-in plugin: The core presence plugin only knows about connections on the local process. If a user connects to instance A, instance B has no idea they are online. The Redis presence extension gives you a single, consistent view of who is online across all instances, with per-entry TTLs so stale entries from crashed instances expire automatically.
Redis 7.4+ required. The extension uses
HPEXPIREfor per-field TTL.createPresenceprobesINFO serveron first use and throws on older servers with a clear migration message. Other extensions in this package are unaffected; the Redis-7.4 requirement is specific tocreatePresence.
Storage layout and O(1) leave
Storage is split into two hashes per topic: presence:topic:{topic} keyed by userKey (backs list() / count()) and presence:user:{topic}:{userKey} keyed by instanceId (backs the leave HLEN check). JOIN_SCRIPT does HSET + HPEXPIRE on both atomically. LEAVE_SCRIPT does HDEL + HLEN; if HLEN drops to zero, HDEL the userKey from the per-topic hash and return 1 (broadcast leave). Per-field TTL via HPEXPIRE replaces the heartbeat-driven cleanup pass: stale entries from crashed instances auto-expire field-by-field via Redis itself.
The leave script’s HLEN check is O(M) in the number of instances the user has fields on (typically 1-10), not O(N) in the topic’s total user count. At realistic auctions / chat scale (10k users x 10 instances = 100k presence fields per topic), a pipelined mass disconnect of 1000 users drops from ~3.6 seconds of Redis-event-loop-blocked Lua time to ~6 ms - a 609x speedup measured head-to-head on real Redis 7.4 + ioredis. Single-leave Lua time is now under 1 ms regardless of topic size.
count() is also more accurate: the previous HLEN-based count returned stale-inclusive counts; per-field auto-expiry means HLEN reflects live users only.
Reliability guarantees
Join rollback: Joins are staged with full rollback on failure. Local state is set up first, then the Redis hash field is written, then the WebSocket is subscribed. If any step fails (circuit breaker trips, Redis is down, WebSocket closed during an async gap), all prior steps are undone - local maps, the Redis field, and any broadcast join event are reversed. This prevents ghost entries that would show a user as online when they never fully connected.
Atomic leaves: Leaves use an atomic Lua script that removes this instance’s field from the hash and then scans remaining fields for the same user key, ignoring stale entries. Leave is only broadcast when no other instance holds a live entry for that user, preventing premature “user left” notifications in multi-instance deployments.
Zombie cleanup: Runs on the heartbeat interval. Each tick, every tracked WebSocket is probed via getBufferedAmount() - if the call throws, the socket is dead and its presence is removed synchronously before the heartbeat writes to Redis. The heartbeat then refreshes timestamps on all live entries via a Redis pipeline and runs a server-side Lua cleanup script that scans the hash and removes any fields whose timestamp exceeds the TTL. This handles crashed instances whose close handlers never fired.
Setup
// src/lib/server/presence.js
import { redis } from './redis.js';
import { createPresence } from 'svelte-adapter-uws-extensions/redis/presence';
export const presence = createPresence(redis, {
key: 'id',
select: (userData) => ({ id: userData.id, name: userData.name }),
heartbeat: 30000,
ttl: 90
}); Usage
// src/hooks.ws.js
import { presence } from '$lib/server/presence';
export async function subscribe(ws, topic, { platform }) {
await presence.join(ws, topic, platform);
}
export async function close(ws, { platform }) {
await presence.leave(ws, platform);
} Options
| Option | Default | Description |
|---|---|---|
key | 'id' | Field for user dedup (multi-tab) |
select | strips __-prefixed and sensitive keys | Extract public fields from userData. Default warns once per process if userData contains sensitive-looking keys (/token\|secret\|password\|auth\|session\|cookie\|jwt\|credential/i). |
heartbeat | 30000 | TTL refresh interval in ms |
ttl | 90 | Per-entry expiry in seconds. Entries from crashed instances expire individually after this period, even if other instances are still active on the same topic. |
keyspaceNotifications | false | When true, psubscribes __keyevent@*__:expired so an empty state event fires on hash expiry. Counter: presence_keyspace_cleanups_total. |
API
| Method | Description |
|---|---|
join(ws, topic, platform) | Add connection to presence |
leave(ws, platform, topic?) | Remove from a specific topic, or all topics if omitted |
sync(ws, topic, platform) | Send list without joining |
list(topic) | Get current users |
count(topic) | Count unique users |
metrics() | Sync snapshot: { totalOnline, heartbeatLatencyMs, staleCleanedTotal }. staleCleanedTotal is always 0 since per-field HPEXPIRE replaces application-side cleanup; the field is kept in the return shape for backward compatibility. |
flushDiffs() | Flush the microtask diff batch synchronously. Test-only. |
clear() | Reset all presence state |
destroy() | Stop heartbeat and subscriber |
hooks | { subscribe, unsubscribe, close, message } - ready-made WebSocket hooks |
Wire shape
The __presence:{topic} channel emits two diff-shaped events plus the heartbeat:
| Event | When | Payload |
|---|---|---|
state | Once per subscribe | {[userKey]: data} flat snapshot. |
diff | Topic broadcasts, batched via setTimeout(0) | {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. |
heartbeat | Server timer (default 30 s) | {[userKey]: data} map. Refreshes existing entries AND re-adds any swept-out entries (extensions 0.5.3). |
Renamed from presence_state / presence_diff in extensions 0.5.8 to match the cursor / groups / replay convention of bare event names scoped to their plugin topic. Hand-rolled wire decoders must use the new names.
Counters: presence_diff_frames_total{topic}, presence_diff_coalesced_total{topic} (Prometheus metric names unchanged). keyspaceNotifications: true mode emits an empty state event on hash expiry. The cross-instance Redis envelope on presence:events:{topic} is unchanged.
The diff buffer flushes via setTimeout(0) (extensions 0.5.9) instead of queueMicrotask, so mass-joins arriving across separate WebSocket message handlers coalesce into one publish per topic rather than fragmenting into N one-entry diffs.
The bundled presence() Svelte client store handles both events transparently.
Reconnect snapshot
Since extensions 0.5.3, the client sends a {type:'presence-snapshot', topic} text frame on every status === 'open' (initial connect + every reconnect). presence.hooks.message handles this by routing through tracker.sync(ws, topic, platform), re-emitting a state frame to the requesting WebSocket. Without this, per-board presence would not self-heal across reconnects in cluster mode.
Wire presence.hooks.message via createMessage({ onUnhandled }) or by destructuring it directly:
// src/hooks.ws.js
import { presence } from '$lib/server/presence';
export const { subscribe, unsubscribe, close, message } = presence.hooks; Or compose with svelte-realtime’s createMessage:
import { createMessage } from 'svelte-realtime/server';
import { presence } from '$lib/server/presence';
export const message = createMessage({
onUnhandled(ws, data, platform) {
presence.hooks.message(ws, { data, platform });
}
}); Closed-WS safety
presence.join() throws WsClosedError (code: 'WS_CLOSED') if the WebSocket closes during any of the six internal async gaps (wsTopics.has pre-probe, getBufferedAmount probe, post-JOIN_SCRIPT check, ws.subscribe, post-subscribe check). State rollback runs unconditionally; the throw is the missing observability signal. Catch shape:
try {
await presence.join(ws, topic, platform);
} catch (err) {
if (err.code !== 'WS_CLOSED') throw err;
// client disconnected mid-join; nothing else to clean up
} WsClosedError is re-exported from 'svelte-adapter-uws-extensions/redis/presence'. Counter: presence_joins_aborted_total{topic, reason="ws_closed"}.
Prometheus metrics
Wire via the prometheus sub-export:
import { wirePresenceMetrics } from 'svelte-adapter-uws-extensions/prometheus';
wirePresenceMetrics(presence, metrics); Gauges: presence_total_online{topic}, presence_heartbeat_latency_ms.
Zero-config hooks
Instead of writing subscribe and close handlers manually, destructure presence.hooks:
// src/hooks.ws.js
import { presence } from '$lib/server/presence';
export const { subscribe, unsubscribe, close, message } = presence.hooks; subscribe handles both regular topics (calls join) and __presence:* topics (calls sync so the client gets the current snapshot). unsubscribe removes the user from a single topic. close calls leave for the whole connection. message handles the {type:'presence-snapshot'} reconnect frame.
Upgrading from 0.4.x:
presence.hooksincludesunsubscribesince 0.4.0 - destructure all three for correct single-topic leave when a client unsubscribes without disconnecting. See Migration 0.4 to 0.5.
If you need custom logic (auth gating, logging), wrap the hooks:
import { presence } from '$lib/server/presence';
export async function subscribe(ws, topic, ctx) {
if (!ctx.platform.getUserData(ws).authenticated) return;
await presence.hooks.subscribe(ws, topic, ctx);
}
export const { close } = presence.hooks; Was this page helpful?