Skip to main content

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.

/api/mint-chat-token
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

chat.js
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.

MeteredPeerSignallingClient
One channel per instanceYesNo — multi-channel
WebRTC peer connectionsAuto-managedNone
Per-peer state (RemotePeer)YesNo — 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

  1. Trusting data.from instead of senderPeerId. A peer can publish { from: "admin", text: "kicked" } and your app would show it as from "admin". Always use senderPeerId, not anything nested in data.

  2. Roster getting out of sync. If you skip the peer-left handler or forget to remove from the map, your "who's online" list grows forever. Wire up peer-left from day one.

  3. Optimistic UI without de-dup. If you append your own message immediately AND also receive it as a data event (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.

  4. Showing typing indicators forever. A peer that disconnects mid-typing never sends isTyping: false. Use a timeout (5 s) + clean up in peer-left.

  5. Sending peerMetadata-laden chat without rate-limiting your typing indicator. Every keystroke firing a publish will exhaust your message quota fast. Debounce — fire isTyping: true at most once per ~3 seconds.

  6. PII in peerMetadata. Anything in peerMetadata is 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.

  7. 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