Cursor
Same API as the core createCursor plugin, but cursor positions are shared across instances via Redis. Each instance broadcasts through its local scheduler, then relays the cadence-aligned update / bulk / remove frames through Redis pub/sub so subscribers on other instances see cursor updates in lockstep with local ones.
Hash entries have a TTL so stale cursors from crashed instances get cleaned up automatically. HSET writes are coalesced onto a snapshotIntervalMs tick (default 100 ms) for ~100x fewer Redis round-trips at scale.
When to use over the built-in plugin: the core cursor plugin only broadcasts to clients on the local process. In a multi-instance setup, users on different instances would not see each other’s cursors. The Redis version relays updates through pub/sub, stores positions in a shared hash for SSR / late-joiner snapshots, and merges peer-relayed cursors with local ones before the next flush so subscribers see one drift-corrected frame per cycle regardless of worker count.
Setup
// src/lib/server/cursors.js
import { redis } from './redis.js';
import { createCursor } from 'svelte-adapter-uws-extensions/redis/cursor';
export const cursors = createCursor(redis, {
throttle: 16, // per-cursor: ~60 Hz
topicThrottle: 16, // per-topic coalesce window
snapshotIntervalMs: 100, // coalesced HSET tick
select: (userData) => ({ id: userData.id, name: userData.name, color: userData.color }),
ttl: 30
}); Usage
Call cursors.attach(ws, topic, platform) when the user joins a room (mirroring presence.join), and cursors.detach when they explicitly leave. attach subscribes the connection to the internal __cursor:{topic} channel via the platform-trust path AND sends a snapshot of existing cursors in one step.
// src/live/room.js
import { live } from 'svelte-realtime/server';
import { cursors } from '$lib/server/cursors';
export const joinRoom = live(async (ctx, roomId) => {
try {
await cursors.attach(ctx.ws, `room:${roomId}`, ctx.platform);
} catch (err) {
if (err.code === 'WS_CLOSED') return; // client disconnected mid-join
throw err;
}
return { ok: true };
});
export const leaveRoom = live(async (ctx, roomId) => {
await cursors.detach(ctx.ws, `room:${roomId}`, ctx.platform);
return { ok: true };
});
export const moveCursor = live.volatile((ctx, roomId, position) => {
cursors.update(ctx.ws, `room:${roomId}`, position, ctx.platform);
}); live.volatile() (added in svelte-realtime 0.5.8) marks moveCursor as fire-and-forget so the client’s .fireAndForget(...) call skips Promise allocation and the response envelope - useful for 60 Hz cursor RPCs.
Wire the reconnect-snapshot handler in hooks.ws.js so per-board cursors re-populate after a network blip:
// src/hooks.ws.js
import { createMessage } from 'svelte-realtime/server';
import { cursors } from '$lib/server/cursors';
export const message = createMessage({
onUnhandled(ws, data, platform) {
cursors.hooks.message(ws, { data, platform });
}
});
export function close(ws, { platform }) {
cursors.remove(ws, platform);
} cursors.hooks.message handles both {type:'cursor'} movement frames AND {type:'cursor-snapshot'} reconnect frames (the client sends one on every status === 'open'). Without this wiring the snapshot frames are no-ops and reconnects don’t re-populate per-board cursors - the dev-only warning at first such frame points at this fix.
update keeps its existing semantics - throttled per user per topic, broadcast to __cursor:{topic} subscribers. The attach / detach shape exists because the adapter blocks wire-level subscribes to __-prefixed topics; attach owns membership via platform.subscribe, which intentionally bypasses the wire-level gate. The legacy hooks.subscribe predicate path is kept as a no-op for source-compat.
Options
| Option | Default | Description |
|---|---|---|
throttle | 16 | Per-cursor min ms between broadcasts per user per topic. Default lowered from 50 ms in 0.5.2. |
topicThrottle | 16 | Per-topic coalesce window in ms (one bulk frame per topic per window). Added in 0.5.2. |
snapshotIntervalMs | 100 | HSET-coalesce tick in ms. The broadcast path queues writes into a per-topic latest-wins map; the timer flushes them as one multi-field HSET per topic per tick. Set to 0 to revert to per-flush inline HSET. Added in 0.5.2. |
select | recursive denylist | Extract user data to broadcast alongside position. Strips __-prefixed and sensitive keys (/token\|secret\|password\|auth\|session\|cookie\|jwt\|credential/i). |
ttl | 30 | Per-entry TTL in seconds (auto-refreshed on each broadcast). Stale entries from crashed instances are filtered out individually. |
Wire format
Identical to the in-memory cursor plugin (split catalog/positions). See Adapter cursor plugin -> Wire format for the table. A single browser bundle works against either backend without per-protocol branches.
The Redis envelope on cursor:events:{topic} is {instanceId, event, payload} per relay. Receivers filter their own instanceId and enqueue inbound update / bulk into a per-topic inboundDirty map; the next local flush emits ONE combined frame covering this worker’s own cursors AND any cursors received from peers since the last flush. CATALOG / JOIN / REMOVE (low-frequency roster events) bypass aggregation and publish immediately - latency matters more than smoothness for those.
API
| Method | Description |
|---|---|
attach(ws, topic, platform) | Opt the connection into receiving cursor updates for the topic. Subscribes to __cursor:{topic} via the platform-trust path AND sends a snapshot of existing cursors. Throws WsClosedError (code: 'WS_CLOSED') on mid-async-gap close. |
detach(ws, topic, platform) | Reverse of attach. Removes the connection from __cursor:{topic} and clears the user’s cursor entry for this topic. |
update(ws, topic, data, platform) | Broadcast cursor position (throttled per user per topic, coalesced per topic). |
remove(ws, platform, topic?) | Remove from a specific topic, or all topics if omitted. |
snapshot(ws, topic, platform) | Send current positions to one connection as catalog + bulk. Called by cursors.hooks.message on the cursor-snapshot reconnect frame. |
list(topic) | Get current positions across all instances. Reads both the Redis hash AND the in-memory pending map so local callers see freshly-broadcast cursors before the next snapshot tick. |
stats() | Scheduler health: { flushes, driftMeanMs, driftMaxMs, dirtyTopicsCurrent, activeTopicsTotal }. Added in 0.5.4. |
clear() | Reset all local and Redis state. |
destroy() | Stop the Redis subscriber and clear timers. |
hooks | { subscribe, close, message } - ready-made WebSocket hooks |
Closed-WS safety
cursors.attach() throws WsClosedError (code: 'WS_CLOSED') if the WebSocket closes during the awaited subscribe-hook gate or any internal async step. State rollback runs unconditionally; the throw is the missing observability signal. Counter: cursor_attaches_aborted_total{topic, reason="ws_closed"}.
WsClosedError is re-exported from 'svelte-adapter-uws-extensions/redis/cursor'. The stable catch shape is err.code === 'WS_CLOSED' so callers do not need to branch by feature.
Performance notes
- The single-timer scheduler (0.5.4) holds at most one
tickTimerper tracker (aimed at the next earliest topic deadline) instead of N per-topic timers. lastFlush += topicThrottleMsis target-relative, so a single late fire (event-loop saturation, GC pause) does not compound across cycles.- The always-tick design (0.5.7) drops the leading-edge synchronous fire: every broadcast goes through the timer, so cross-task-boundary co-arrivals (the actual production shape) coalesce into one
bulkregardless of how many JS task boundaries separate them. - Receiver-side aggregation (0.5.4) merges peer-relayed cursors with local cursors before the next flush, so subscribers see one combined frame per
topicThrottleMscycle instead of a tight “doublet” per cycle.
Was this page helpful?