Presence & Chat
Live classroom rosters, livestream chat, multiplayer lobbies, "who's online" sidebars — anything that boils down to "show me who's here + what they're saying." This is MeteredPeer's sweet spot when you skip the WebRTC parts.
You can also build this with SignallingClient (no MeteredPeer overhead) — there's a section at the end on when to prefer each.
What you'll build
A chat room with:
- Live roster showing usernames and avatars (from
peerMetadata) - Messages broadcast to everyone in the channel
- Typing indicators (transient broadcasts that don't persist)
- Smooth join / leave UI
Step 1 — Mint a JWT with peerMetadata
peerMetadata is what lights up the roster — it carries the username, avatar, role, anything else you want shown to other peers.
const jwt = require("jsonwebtoken");
app.get("/api/mint-chat-token", requireAuth, (req, res) => {
const token = jwt.sign(
{
sub: req.user.id,
channels: [`app_${req.appId}/chat-*`],
permissions: ["publish", "subscribe", "presence"],
peerMetadata: {
username: req.user.name,
avatarUrl: req.user.avatar,
role: req.user.role, // "instructor" | "student" | "guest"
},
exp: Math.floor(Date.now() / 1000) + 3600,
},
process.env.SK_SECRET,
{ algorithm: "HS256", header: { alg: "HS256", kid: process.env.SK_ID } },
);
res.json({ token });
});
Note that we omitted send from permissions — this chat doesn't use directed peer-to-peer (private DMs would add it back).
Step 2 — Browser code
import { MeteredPeer } from "@metered-ca/peer";
const peer = new MeteredPeer({
tokenProvider: async () => {
const r = await fetch("/api/mint-chat-token");
return (await r.json()).token;
},
});
// Live roster: peerId → { username, avatarUrl, role }
const roster = new Map();
peer.on("peer-joined", ({ peer: remote }) => {
roster.set(remote.id, remote.metadata);
renderRoster();
appendSystemMessage(`${remote.metadata.username} joined`);
});
peer.on("peer-left", ({ peer: remote }) => {
roster.delete(remote.id);
renderRoster();
appendSystemMessage(`${remote.metadata?.username || "Someone"} left`);
});
// Messages
peer.on("data", ({ senderPeerId, data }) => {
const senderMeta = roster.get(senderPeerId);
if (data.type === "chat") {
appendChatMessage(senderMeta, data.text);
} else if (data.type === "typing") {
showTypingIndicator(senderPeerId, senderMeta, data.isTyping);
}
});
// UI handlers
async function sendMessage(text) {
await peer.send({ type: "chat", text });
appendChatMessage(localMeta, text); // optimistic: show our own message
}
let typingTimer;
function onInputChange() {
if (!typingTimer) peer.send({ type: "typing", isTyping: true });
clearTimeout(typingTimer);
typingTimer = setTimeout(() => {
peer.send({ type: "typing", isTyping: false });
typingTimer = null;
}, 3000);
}
let localMeta = null;
peer.on("joined", () => {
// Find our own metadata from the welcome's roster snapshot.
// (Server includes us in our own first presence event.)
// Or fetch it from your backend separately.
localMeta = { username: "You", avatarUrl: "..." };
});
await peer.join("app_xyz/chat-room-42");
Step 3 — Reading the roster on first join
When peer.join(channel) resolves, the presence event has already fired with the initial roster. The SDK translates each joined entry into a peer-joined event for you. So by the time your handler runs, every existing peer has fired peer-joined and been added to your roster map.
await peer.join("app_xyz/chat-room-42");
// roster now contains every peer who was in the room when you joined.
console.log(`${roster.size} people are here`);
Step 4 — Including sender metadata on each message
By default, peer.send doesn't include the sender's peerMetadata on the receiver's data event — only the senderPeerId. This keeps message frames small.
For most chat apps, you don't need per-message metadata anyway — you already know everyone's metadata from the peer-joined event (you put it in the roster map). Just look it up:
peer.on("data", ({ senderPeerId, data }) => {
const senderMeta = roster.get(senderPeerId);
appendChatMessage(senderMeta, data.text);
});
If you need metadata directly on each message (e.g., for a transient send before the sender's peer-joined has propagated), opt in via subscribe:
await peer.join("chat-room-42", { includeSenderMetadata: true });
// Now data events carry `senderMetadata` as well as `senderPeerId`.
peer.on("data", ({ senderPeerId, senderMetadata, data }) => {
// senderMetadata is the sender's peerMetadata — server-stamped
});
Costs: a bit more bandwidth per message + counts against your message size limit. Most chat apps don't need it; the roster-lookup pattern is enough.
Step 5 — Typing indicators (the right way)
Typing indicators are transient — you don't store them. They're just a broadcast with a short timeout on the receiver side.
const typingTimeouts = new Map(); // peerId → timeoutId
peer.on("data", ({ senderPeerId, data }) => {
if (data.type !== "typing") return;
if (data.isTyping) {
showTypingIndicator(senderPeerId);
// Auto-clear if we don't get a follow-up within 5 s
// (sender may have disconnected mid-typing)
clearTimeout(typingTimeouts.get(senderPeerId));
typingTimeouts.set(
senderPeerId,
setTimeout(() => hideTypingIndicator(senderPeerId), 5000),
);
} else {
hideTypingIndicator(senderPeerId);
clearTimeout(typingTimeouts.get(senderPeerId));
}
});
The auto-clear timeout is critical — if a peer disconnects mid-typing, you'll never get the isTyping: false follow-up, and the indicator would stay forever. The peer-left event also fires for them, which gives you a second chance to clean up.
Should I use SignallingClient instead?
SignallingClient is MeteredPeer minus the per-peer WebRTC orchestration. For chat that doesn't use WebRTC, it's smaller and lets you subscribe to multiple channels from one connection.
MeteredPeer | SignallingClient | |
|---|---|---|
| One channel per instance | Yes | No — multi-channel |
| WebRTC peer connections | Auto-managed | None |
Per-peer state (RemotePeer) | Yes | No — you'd build your own roster from presence events |
| Code lines for a chat room | ~30 | ~25 |
| Bundle size | ~30 KB gzipped | ~10 KB gzipped |
If your app is purely chat / presence (no future plans to add video), SignallingClient is the cleaner fit:
import { SignallingClient } from "@metered-ca/peer";
const client = new SignallingClient({ tokenProvider });
await client.connect();
const roster = new Map();
client.on("presence", ({ channel, joined, left }) => {
for (const p of joined) roster.set(p.peerId, p.metadata);
for (const p of left) roster.delete(p.peerId);
renderRoster();
});
client.on("message", ({ from, data }) => {
if (data.type === "chat") appendChatMessage(roster.get(from), data.text);
});
await client.subscribe("app_xyz/chat-room-42");
await client.publish("app_xyz/chat-room-42", { type: "chat", text: "hi!" });
The trade-off: with SignallingClient you're responsible for diffing presence events into a roster (the SDK gives you joined / left arrays per event; you reconcile your own map). MeteredPeer does that for you with peer-joined / peer-left.
Pitfalls
Trusting
data.frominstead ofsenderPeerId. A peer can publish{ from: "admin", text: "kicked" }and your app would show it as from "admin". Always usesenderPeerId, not anything nested indata.Roster getting out of sync. If you skip the
peer-lefthandler or forget to remove from the map, your "who's online" list grows forever. Wire uppeer-leftfrom day one.Optimistic UI without de-dup. If you append your own message immediately AND also receive it as a
dataevent (which the server doesn't echo back to you, so this shouldn't happen — but might if you also subscribe to your own outgoing channel), you'll see doubles. The server does NOT echo your own publishes back to you, but if you accidentally subscribe to your own outgoing direct messages, you might.Showing typing indicators forever. A peer that disconnects mid-typing never sends
isTyping: false. Use a timeout (5 s) + clean up inpeer-left.Sending
peerMetadata-laden chat without rate-limiting your typing indicator. Every keystroke firing apublishwill exhaust your message quota fast. Debounce — fireisTyping: trueat most once per ~3 seconds.PII in
peerMetadata. Anything inpeerMetadatais visible to every other peer in the channel. Don't put email addresses, phone numbers, or anything else you wouldn't want a stranger to see. Display names + avatars only.Long-running chat without reconnect handling. Real users keep tabs open for hours. Read Reconnect Best Practices — chat that goes silent during a network blip looks broken.
See also
- AI Agent Communication — similar broadcast / direct patterns, agent twist
SignallingClientreference — for chat-only / no-WebRTC apps- Authentication —
peerMetadatawalkthrough - Raw WebSocket version