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

OptionDefaultDescription
throttle16Per-cursor min ms between broadcasts per user per topic. Default lowered from 50 ms in 0.5.2.
topicThrottle16Per-topic coalesce window in ms (one bulk frame per topic per window). Added in 0.5.2.
snapshotIntervalMs100HSET-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.
selectrecursive denylistExtract user data to broadcast alongside position. Strips __-prefixed and sensitive keys (/token\|secret\|password\|auth\|session\|cookie\|jwt\|credential/i).
ttl30Per-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

MethodDescription
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 tickTimer per tracker (aimed at the next earliest topic deadline) instead of N per-topic timers.
  • lastFlush += topicThrottleMs is 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 bulk regardless 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 topicThrottleMs cycle instead of a tight “doublet” per cycle.

Was this page helpful?