Skip to main content

Collaborative Apps

Live cursors / selections, shared document state, multiplayer mini-games, live dashboards. Characterized by many small high-frequency events per peer — the opposite end of the spectrum from chat.

Design trade-off — high frequency means metadata-via-presence

Chat apps want includeSenderMetadata: true so every message renders standalone. Collaborative apps want the opposite: keep messages tiny, cache identity once via presence, render from the cache.

FieldChatCursorsGame state
includeSenderMetadata on subscribetruefalsefalse
Messages/sec per peer (typical)< 110–6010–30
Identity needed per messageyes (render)yes (color/name on cursor)yes (avatar on player)
StrategyStamp every messageCache from presence.joined, look up by fromSame

This makes a big difference at scale. A cursor message without metadata is ~50 bytes; with metadata it's ~250. At 30 Hz × 20 peers × 1-hour session = ~108k events × the saved 200 bytes = ~20 MB saved per session, per participant.


A live cursor implementation

backend — mint with peerMetadata, no special claims
const token = jwt.sign({
sub: user.id,
exp: ...,
channels: [`doc-${docId}`],
peerMetadata: {
userId: user.id,
username: user.displayName,
cursorColor: user.colorAssignment, // assigned server-side per session
},
}, SECRET, { algorithm: "HS256", header: { alg: "HS256", kid: KEY_ID } });
client — populate roster from presence, broadcast cursor positions
const ws = new WebSocket(`wss://rms.metered.ca/v1?token=${token}`);

// Cache: peerId → { username, cursorColor }
const peerRoster = new Map();

ws.onmessage = (evt) => {
const msg = JSON.parse(evt.data);

if (msg.type === "welcome") {
ws.send(JSON.stringify({
type: "subscribe",
channel: `doc-${docId}`,
// ✨ NOT setting includeSenderMetadata — keep messages tiny.
requestId: "sub-1",
}));
}

if (msg.type === "presence") {
// Populate / update the roster.
for (const peer of msg.joined) {
peerRoster.set(peer.peerId, peer.metadata ?? {});
}
for (const peer of msg.left) {
peerRoster.delete(peer.peerId);
removeCursor(peer.peerId);
}
}

if (msg.type === "message" && msg.data.kind === "cursor") {
const peer = peerRoster.get(msg.from);
if (!peer) return; // they might have left between presence.left and a stale cursor
renderCursor(msg.from, {
x: msg.data.x,
y: msg.data.y,
color: peer.cursorColor,
username: peer.username,
});
}
};

// Throttle cursor broadcasts to 30 Hz max.
let lastSentAt = 0;
document.addEventListener("mousemove", (e) => {
const now = performance.now();
if (now - lastSentAt < 33) return; // ~30 Hz
lastSentAt = now;
ws.send(JSON.stringify({
type: "publish",
channel: `doc-${docId}`,
data: { kind: "cursor", x: e.clientX, y: e.clientY },
}));
});

Watch the per-connection token bucket

The default bucket is 100 msg/sec sustained, 200 burst. A naive mousemove listener can hit 1000+ Hz on a fast hand — your messages would land in the burst capacity briefly, then trigger a 4011 OverMessageRate close once the bucket empties.

Throttling on the client side (above example: 30 Hz max) keeps you safely in the budget. Always throttle high-frequency event sources before publishing — the server's abuse bound is meant to catch runaways, not be the throttle itself.


Stale state on reconnect — bootstrap from REST

When a peer reconnects after a brief disconnect, they want the current document state, not just future updates. The pattern:

  1. On disconnect, your local cursor/state cache stays — render last-known positions in faded mode.
  2. On reconnect, subscribe to the channel; the one-shot presence.joined event tells you who's currently in the room.
  3. For state that needs catch-up (e.g., last-known cursor positions for everyone), call your own backend's GET /api/docs/abc/state endpoint — Realtime Messaging doesn't replay past messages.

The Realtime Messaging server intentionally doesn't store message history. If you need replay/catch-up:


Selective broadcast for huge rooms

If 1000 viewers are looking at the same dashboard but only 10 are interacting:

  • The 1000 viewers subscribe to dashboard-xyz/state (read-only)
  • The 10 editors subscribe additionally to dashboard-xyz/cursors (high-frequency)
  • Cursors fan out only to the 10, not the 1000 → cost / bandwidth scales correctly

Channel design matters more than any optimization at the client layer.


Multi-region considerations

For globally-distributed editors / players, the round-trip-time floor is set by where you connect to Metered. Pick the geographically-closest region for your end users. Cursor sync at 30 Hz feels different at 30ms RTT vs 200ms RTT.


Recap — when to stamp vs cache

PatternStamp per-message?
ChatYes — includeSenderMetadata: true
Reactions, emoji burstsYes
Cursor syncNo — cache from presence
Game state ticksNo — cache from presence
Slow-moving "live dashboard" widget stateEither; lean Yes if updates are < 1 Hz, No if higher

When in doubt: high-frequency channels cache; low-frequency channels stamp.


See also