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.
| Field | Chat | Cursors | Game state |
|---|---|---|---|
includeSenderMetadata on subscribe | true | false | false |
| Messages/sec per peer (typical) | < 1 | 10–60 | 10–30 |
| Identity needed per message | yes (render) | yes (color/name on cursor) | yes (avatar on player) |
| Strategy | Stamp every message | Cache from presence.joined, look up by from | Same |
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
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 } });
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:
- On disconnect, your local cursor/state cache stays — render last-known positions in faded mode.
- On reconnect, subscribe to the channel; the one-shot
presence.joinedevent tells you who's currently in the room. - For state that needs catch-up (e.g., last-known cursor positions for everyone), call your own backend's
GET /api/docs/abc/stateendpoint — Realtime Messaging doesn't replay past messages.
The Realtime Messaging server intentionally doesn't store message history. If you need replay/catch-up:
- Keep authoritative state in your own database
- Subscribe to the channel with a backend "logger" that persists every message (see Live Presence & Chat → Recording chat for replay)
- Serve catch-up from your backend, not from the realtime layer
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
| Pattern | Stamp per-message? |
|---|---|
| Chat | Yes — includeSenderMetadata: true |
| Reactions, emoji bursts | Yes |
| Cursor sync | No — cache from presence |
| Game state ticks | No — cache from presence |
| Slow-moving "live dashboard" widget state | Either; lean Yes if updates are < 1 Hz, No if higher |
When in doubt: high-frequency channels cache; low-frequency channels stamp.
See also
- Presence & Metadata — the full stamping rules
- Rate Limits — the per-connection token bucket
- Channels — channel naming patterns