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

OptionDefaultDescription
throttle16Per-cursor max broadcast interval per user per topic (ms). Default lowered from 50 ms to 16 ms in adapter 0.5.2.
topicThrottle16Per-topic coalesce window (ms). All movers dirty within a window flush as one bulk frame at the window boundary. Added in 0.5.2.
selectidentity(userData) -> publicData - extract public fields for the cursor payload
maxConnections1_000_000Hard cap on tracked connections
maxTopics1_000_000Hard cap on active topic registry
maxTopicLength256Topic strings longer than this are rejected synchronously. Pass Infinity to disable.
maxDataBytes8192 (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.

EventPayloadSent 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

MethodDescription
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:

  1. throttle caps how often a single user broadcasts on a single topic. The first move on a (ws, topic) pair emits join (catalog channel) and queues the position; subsequent moves within the window overwrite the queued position.
  2. topicThrottle caps 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 one bulk array; a single mover in the window emits one update. 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?