Skip to main content

Live Presence & Chat

Classroom moderation, proctoring, livestream chat, "who's online" dashboards. The pattern is the same: one channel per room, every participant subscribes, peerMetadata carries identity so every message and every presence event renders with the right name and avatar.

The minimum viable chat

backend — mint a JWT with identity
const token = jwt.sign({
sub: user.id,
exp: Math.floor(Date.now()/1000) + 3600,
channels: [`room-${roomId}`],
permissions: ["publish", "subscribe", "presence"],
peerMetadata: {
userId: user.id,
username: user.displayName,
profilePic: user.avatarUrl,
role: user.role, // "student" | "teacher" | "moderator"
},
}, SECRET, { algorithm: "HS256", header: { alg: "HS256", kid: KEY_ID } });
client — subscribe with includeSenderMetadata: true
const ws = new WebSocket(`wss://rms.metered.ca/v1?token=${token}`);

const participants = new Map(); // peerId → { username, profilePic, role }

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

if (msg.type === "welcome") {
ws.send(JSON.stringify({
type: "subscribe",
channel: `room-${roomId}`,
// ✨ Every message will carry the sender's peerMetadata
includeSenderMetadata: true,
requestId: "sub-1",
}));
}

if (msg.type === "presence") {
// Update the roster
for (const peer of msg.joined) {
participants.set(peer.peerId, peer.metadata ?? {});
renderParticipant(peer.peerId, peer.metadata);
}
for (const peer of msg.left) {
participants.delete(peer.peerId);
removeParticipant(peer.peerId);
}
}

if (msg.type === "message") {
// Render the chat bubble. No backend lookup needed — every
// message arrives stamped with the author's display info.
renderChatBubble({
authorId: msg.from,
username: msg.fromMetadata?.username ?? msg.from,
avatar: msg.fromMetadata?.profilePic,
role: msg.fromMetadata?.role,
text: msg.data.text,
timestamp: new Date(),
});
}
};

function sendChatMessage(text) {
ws.send(JSON.stringify({
type: "publish",
channel: `room-${roomId}`,
data: { text, timestamp: Date.now() },
requestId: `chat-${Date.now()}`,
}));
}

That's the whole pattern. Three notes:

  1. includeSenderMetadata: true — without this, channel messages arrive with from: "peer-id-xyz" but no fromMetadata. Setting it on makes every chat bubble render-ready.
  2. Presence-driven roster — the one-shot presence.joined you get on first subscribe lists everyone already in the room with their peerMetadata. No "who's here?" REST query needed.
  3. No DB roundtrip per message — your client renders the chat from the message alone.

Adding moderation actions

Classroom proctoring: a teacher needs to kick a disruptive student, OR broadcast a system message ("recording started").

Kick a student

From your backend, when a teacher clicks "Remove":

backend
await fetch(
`https://rms.metered.ca/v1/peers/${encodeURIComponent(studentPeerId)}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${process.env.METERED_REALTIME_SK}` },
},
);

The student's WebSocket closes with code 4020 AdminDisconnect. Their client SDK should branch on this code to surface "You've been removed from this room" instead of auto-reconnecting.

Broadcast a system message

backend
await fetch(
`https://rms.metered.ca/v1/channels/${encodeURIComponent(`room-${roomId}`)}/publish`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.METERED_REALTIME_SK}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
data: { kind: "moderation-notice", text: "Recording has started." },
from: "system",
}),
},
);

The server publishes from "system" as the peerId — no fromMetadata since the publisher is your backend, not a peer with a JWT.

Have your client render from === "system" differently (centered, italicized, no avatar) so users distinguish system messages from peer chat.


Typing indicators

Typing indicators are a high-frequency event you DON'T want to bill as messages forever. Two patterns:

Pattern A — broadcast on the same channel (simple, fits in plan). Throttle on the client to 1 event per ~500ms per peer:

let lastTypingAt = 0;
input.addEventListener("input", () => {
const now = Date.now();
if (now - lastTypingAt < 500) return;
lastTypingAt = now;
ws.send(JSON.stringify({
type: "publish",
channel: `room-${roomId}`,
data: { kind: "typing", expiresInMs: 1500 },
}));
});

// Client side: render "Alice is typing..." for 1500ms after each event

Pattern B — separate channel for very large rooms. Subscribers who want typing indicators subscribe to room-${roomId}/typing; everyone else doesn't pay the bandwidth.


Recording chat for replay

Subscribe to the chat channel from your backend (via WebSocket, NOT REST — REST publishes don't receive). Persist messages as they arrive.

For the customer use case where this matters (compliance, audit, classroom replay), the typical pattern is a small "logger" Node.js service that:

  1. Connects to wss://rms.metered.ca/v1 with a backend-owned sk_-minted JWT that has subscribe permission on all relevant channels
  2. Subscribes to room-* (wildcard works if the key's channelPatterns is room-*)
  3. Persists each message event to your database

The signalling-server doesn't have a server-side "replay" or "history" feature today — you build it via this logger pattern. (Replay-on-subscribe is on the roadmap.)


Tips that matter at scale

  • Cap roster rendering — if a room has 10,000 viewers, don't render every participant in the sidebar. Show a count + "show all" toggle.
  • Presence events fan out to every subscriber. A 10k-room with high join/leave churn will see 10k presence events per join. Either keep rooms smaller, or move presence to a separate "moderators-only" channel that the broadcasters don't subscribe to.
  • Don't put every user's full bio in peerMetadata — keep it to display fields (username, avatar, role). Big rooms × big metadata = big bandwidth.

See also