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
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 } });
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:
includeSenderMetadata: true— without this, channel messages arrive withfrom: "peer-id-xyz"but nofromMetadata. Setting it on makes every chat bubble render-ready.- Presence-driven roster — the one-shot
presence.joinedyou get on first subscribe lists everyone already in the room with theirpeerMetadata. No "who's here?" REST query needed. - 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":
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
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:
- Connects to
wss://rms.metered.ca/v1with a backend-owned sk_-minted JWT that hassubscribepermission on all relevant channels - Subscribes to
room-*(wildcard works if the key'schannelPatternsisroom-*) - Persists each
messageevent 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
- Presence & Metadata — the cheat-sheet for what goes where
- REST API → Peers — the kick endpoint
- REST API → Channels — server-side broadcast