Cursor (Ephemeral State)
Lightweight fire-and-forget broadcasting for transient state - mouse cursors, text selections, drag positions, drawing strokes. Built-in scheduler with cross-socket coalescing ensures crowded rooms stay smooth. Auto-cleanup on disconnect.
Setup
// src/lib/server/cursors.js
import { createCursor } from 'svelte-adapter-uws/plugins/cursor';
export const cursors = createCursor({
throttle: 16, // per-cursor: at most one broadcast per 16ms (~60 Hz)
topicThrottle: 16, // per-topic: coalesce all dirty movers into one frame per 16ms
select: (userData) => ({ id: userData.id, name: userData.name, color: userData.color })
}); Both throttle and topicThrottle default to 16 ms (~60 Hz). For a 120 Hz demo, halve them to 8. To disable per-topic coalescing entirely (every broadcast goes straight out), pass topicThrottle: 0.
Options
| Option | Default | Description |
|---|---|---|
throttle | 16 | Per-cursor max broadcast interval per user per topic (ms). Default lowered from 50 ms to 16 ms in adapter 0.5.2. |
topicThrottle | 16 | Per-topic coalesce window (ms). All movers dirty within a window flush as one bulk frame at the window boundary. Added in 0.5.2. |
select | identity | (userData) -> publicData - extract public fields for the cursor payload |
maxConnections | 1_000_000 | Hard cap on tracked connections |
maxTopics | 1_000_000 | Hard cap on active topic registry |
maxTopicLength | 256 | Topic strings longer than this are rejected synchronously. Pass Infinity to disable. |
maxDataBytes | 8192 (8 KB) | JSON-encoded cursor data larger than this is rejected. Cursor positions are typically ~30 bytes; the cap blocks broadcast amplification. |
The 256-char topic cap and 8 KB data cap are defense-in-depth bounds against unbounded resource use. A wire-supplied 1 MB topic name would otherwise anchor a 1 MB heap entry per (topic, user) until the entry’s lifetime elapses; an attacker-shaped 1 MB cursor data would broadcast that payload to every subscriber. The caps reject with a typed error before any state lands.
topicThrottle is the bandwidth lever for crowded rooms: rather than fan out one frame per cursor per tick, the server emits one bulk array per topic per window carrying every cursor that moved in that window. Bandwidth per peer scales with active-mover count, not with mover-count times per-mover rate.
Wire format
Positions and user metadata flow on separate channels. The split (added in adapter 0.5.2) keeps per-frame wire bytes minimal: position frames are ~16 bytes per cursor (key + coords), and the user object (name, color, avatar) flows only when a user first appears.
| Event | Payload | Sent by |
|---|---|---|
catalog | [{key, user}, ...] | snapshot() - initial roster to a single new subscriber |
join | {key, user} | first update() on a (ws, topic) pair |
update | {key, data} | single-mover position frame |
bulk | [{key, data}, ...] | multi-mover coalesced position frame |
remove | {key} | remove() or hooks.close |
The cluster-aware Redis cursor in extensions speaks the same wire format, so the same client bundle works against either backend.
Server usage
Use the hooks helper for zero-config cursor handling. The message hook handles cursor and cursor-snapshot messages automatically, and close calls remove(). The hooks verify that the sender is subscribed to the __cursor:{topic} channel before processing - clients that haven’t passed the subscribe hook for that topic are silently rejected.
// src/hooks.ws.js
import { cursors } from '$lib/server/cursors';
export function message(ws, ctx) {
if (cursors.hooks.message(ws, ctx)) return;
// handle other messages...
}
export const close = cursors.hooks.close; For composition with svelte-realtime’s createMessage:
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 });
}
}); For custom auth or topic filtering, handle the messages manually:
export function message(ws, { data, platform }) {
const msg = JSON.parse(Buffer.from(data).toString());
if (msg.type === 'cursor') {
cursors.update(ws, msg.topic, { x: msg.x, y: msg.y }, platform);
}
if (msg.type === 'cursor-snapshot') {
cursors.snapshot(ws, msg.topic, platform);
}
}
export function close(ws, { platform }) {
cursors.remove(ws, platform);
} Server API
| Method | Description |
|---|---|
cursors.update(ws, topic, data, platform) | Broadcast position (per-cursor + per-topic throttled). Emits join once per (ws, topic). |
cursors.remove(ws, platform) | Remove from all topics, broadcast remove per topic |
cursors.snapshot(ws, topic, platform) | Send current positions to one connection as catalog + bulk (initial sync) |
cursors.list(topic) | Current positions (for SSR) |
cursors.stats() | Scheduler health: { flushes, driftMeanMs, driftMaxMs, dirtyTopicsCurrent, activeTopicsTotal } |
cursors.clear() | Reset all state and timers |
Client usage
<script>
import { cursor, move } from 'svelte-adapter-uws/plugins/cursor/client';
const positions = cursor('canvas');
function onmousemove(e) {
move('canvas', { x: e.clientX, y: e.clientY });
}
</script>
<div on:mousemove={onmousemove}>
{#each [...$positions] as [key, { user, data }] (key)}
<div
class="cursor-dot"
style="left: {data.x}px; top: {data.y}px; background: {user.color}"
>
{user.name}
</div>
{/each}
</div> move(topic, data) (added in 0.5.2) is the recommended path for sending cursor updates. Calls are coalesced via requestAnimationFrame so even a 1000 Hz high-DPI mouse collapses to at most one send per repaint, matching the server-side topicThrottle default. Multi-topic callers do not clobber each other. No-op in non-browser environments (SSR-safe).
The client store is a Readable<Map<string, { user, data }>>. The Map updates when cursors move, join, or disconnect. Internally the store merges the catalog/join stream (user metadata) with the update/bulk stream (positions); positions whose user has not yet been seen are withheld until the matching join arrives - they appear on the next render once the catalog catches up.
Initial sync and reconnect
The cursor(topic) store sends a { type: 'cursor-snapshot', topic } message every time the WebSocket connection opens - both on first connect and on every reconnect. The server’s cursors.hooks.message (or your manual handler calling cursors.snapshot(ws, topic, platform)) sends a catalog event followed by a bulk event back to the requesting client. Late joiners see existing cursors immediately.
The cursor() function accepts an optional second argument with a maxAge option (in milliseconds). When set, cursor entries that haven’t received a position update within that window are automatically removed:
const positions = cursor('canvas', { maxAge: 30_000 }); How throttling works
The cursor plugin uses two layers of throttle:
throttlecaps how often a single user broadcasts on a single topic. The first move on a (ws, topic) pair emitsjoin(catalog channel) and queues the position; subsequent moves within the window overwrite the queued position.topicThrottlecaps how often a topic emits a frame at all. Every move appends to the topic’s dirty set and shares a single tracker-wide timer that fires once per cadence cycle. Multiple movers in the same window coalesce into onebulkarray; a single mover in the window emits oneupdate. There is no synchronous leading-edge fire: every flush goes through the tick, so movers arriving from different sockets (each a separate JS task in production) batch into the same frame regardless of how many task boundaries separate them.
throttle: 16, topicThrottle: 16
t=0 A.update({x:0}) --> 'join' A (catalog channel)
position queued in topic dirty set
t=4 B.update({x:0}) --> 'join' B (catalog channel)
position queued in topic dirty set
t=8 A.update({x:5}) --> queued (entry-level throttle waits to t=16)
t=16 [tick timer fires] --> 'bulk' [{key:A, data:{x:5}}, {key:B, data:{x:0}}] Latency cost vs. the alternate “fire-the-first-mover-synchronously” design: the first mover on an idle topic waits up to topicThrottleMs before its frame leaves. At the default 16 ms (~60 Hz) that’s one frame-budget, below the perceptual floor for cursor smoothness. The cost buys cross-socket coalescing - without it, the first mover from each socket fragments out as its own single-cursor update because uWS dispatches each WS message as its own JS task and microtasks drain between dispatches.
The scheduler is observable via cursors.stats() - operators can spot sustained event-loop saturation (driftMeanMs > topicThrottle) or one-off GC pauses (driftMaxMs spikes) from the same accessor.
Closed-WS safety
cursors.attach() (used by the Redis variant) throws WsClosedError (code: 'WS_CLOSED') if the WebSocket closes during an internal async gap. The in-memory update() path swallows closed-WS exceptions and silently no-ops (the cursor is being removed anyway). Counter: cursor_attaches_aborted_total for the Redis variant.
Limitations
- In-memory. Cursor positions live in the process. In cluster mode, each worker tracks its own connections. For cross-instance cursor sharing, use the Redis-backed variant from the extensions package.
- No persistence. Positions are lost on restart. This is intentional - cursors are ephemeral.
Was this page helpful?