Presence & Metadata
Two of the JWT claims look the same but behave very differently. This page is the canonical guide to which one to use where.
metadata vs peerMetadata — at a glance
| Claim | Where it goes | Who sees it | Use it for |
|---|---|---|---|
metadata | welcome.metadata (delivered once at connect) | Only the connecting peer | TURN ICE servers, server-issued user-private config, one-time bootstrap data |
peerMetadata | Stamped on presence (always), direct (always), message (opt-in via subscribe) | Other peers in the same channel / direct conversation | userId, username, profilePic, role tags — anything OTHER peers should see |
Rule of thumb: if it's only for the connecting user → metadata. If other peers need to see it → peerMetadata.
metadata — connection-private welcome bag
The metadata JWT claim is delivered ONCE in the welcome message and never reaches any other peer. The server does not inspect its shape; it's an opaque pass-through.
Typical use: TURN ICE servers for WebRTC
{
"sub": "alice",
"exp": 1715539200,
"channels": ["call-xyz"],
"permissions": ["publish", "subscribe", "presence", "send"],
"metadata": {
"iceServers": [
{ "urls": ["stun:stun.relay.metered.ca:80"] },
{ "urls": ["turn:global.relay.metered.ca:80"], "username": "u", "credential": "c" },
{ "urls": ["turn:global.relay.metered.ca:443"], "username": "u", "credential": "c" },
{ "urls": ["turns:global.relay.metered.ca:443"], "username": "u", "credential": "c" }
]
}
}
{
"type": "welcome",
"peerId": "alice",
"expiresAt": 1715539200,
"serverTime": 1715535600,
"maxMessageSize": 65536,
"metadata": {
"iceServers": [
{ "urls": ["stun:stun.relay.metered.ca:80"] },
...
]
}
}
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === "welcome") {
const pc = new RTCPeerConnection({
iceServers: msg.metadata?.iceServers ?? [],
});
// ... continue with WebRTC setup
}
});
The full TURN-credential-via-JWT flow — including how to mint the TURN credential from your backend with the Metered TURN REST API — is in the WebRTC Signalling guide.
Other things to put in metadata
- App version compatibility checks (
{ minClientVersion: "2.4.0" }) - Feature flags scoped to this connection
- Recording-policy hints (
{ recordingEnabled: false }) - Customer-issued one-time tokens for follow-on calls (e.g., an analytics-service token)
What NOT to put in metadata
Anything other peers should see. They won't; the field is connection-private. Put those in peerMetadata instead.
Size limits
- The
metadataclaim is hard-capped at 8 KB serialized when minting viaPOST /v1/tokens. JWTs over a few KB can also trip WebSocket-upgrade-URL-length limits on some HTTP proxies — in practice, keepmetadata(including iceServers) under ~6 KB to be safe.
peerMetadata — per-peer identity bag
The peerMetadata JWT claim is the customer's identity bag for THIS peer. The server stamps it onto outbound events that other peers see.
Typical use: user identity
{
"sub": "u_alice_123",
"exp": 1715539200,
"channels": ["app_abc/room-1"],
"peerMetadata": {
"userId": "u_alice_123",
"username": "Alice Anderson",
"profilePic": "https://cdn.example.com/u/alice.jpg",
"role": "presenter"
}
}
Where it gets stamped
There are three places peerMetadata reaches other peers. Two are automatic, one is opt-in.
1. Presence events — ALWAYS stamped
Every presence event carries the relevant peers' peerMetadata under each entry's metadata field:
{
"type": "presence",
"channel": "app_abc/room-1",
"joined": [
{
"peerId": "u_alice_123",
"metadata": {
"userId": "u_alice_123",
"username": "Alice Anderson",
"profilePic": "https://cdn.example.com/u/alice.jpg",
"role": "presenter"
}
}
],
"left": []
}
This means as soon as a peer joins, every other peer in the room learns who they are. No need for a backend round-trip per peer to resolve display names / avatars.
2. Direct messages — ALWAYS stamped
When a peer sends a send (direct message), the recipient sees the sender's peerMetadata under fromMetadata:
{
"type": "direct",
"from": "u_alice_123",
"fromMetadata": {
"userId": "u_alice_123",
"username": "Alice Anderson",
"profilePic": "https://cdn.example.com/u/alice.jpg",
"role": "presenter"
},
"data": { "kind": "offer", "sdp": "..." }
}
Direct messages are 1:1 so the byte cost of stamping is minimal — it's always on.
3. Channel messages — OPT-IN at subscribe time
By default, channel message frames do NOT carry peerMetadata. To opt in, set includeSenderMetadata: true when you subscribe:
{
"type": "subscribe",
"channel": "app_abc/room-1",
"includeSenderMetadata": true,
"requestId": "sub-1"
}
Now every message you receive on that channel carries fromMetadata:
{
"type": "message",
"channel": "app_abc/room-1",
"from": "u_alice_123",
"data": { "text": "hi" }
}
{
"type": "message",
"channel": "app_abc/room-1",
"from": "u_alice_123",
"fromMetadata": {
"userId": "u_alice_123",
"username": "Alice Anderson",
"profilePic": "https://cdn.example.com/u/alice.jpg",
"role": "presenter"
},
"data": { "text": "hi" }
}
When to turn includeSenderMetadata on
| Channel pattern | Recommended | Why |
|---|---|---|
| Chat messages (≤ a few per second per user) | ON | Frees the client from maintaining a peerId → display data lookup table — every message renders standalone. |
| Reaction events (likes, emoji bursts) | ON | Same reason. The metadata stamp is cheap relative to user-perceived latency. |
| Cursor sync (10–60 Hz per peer) | OFF | Use presence events to learn peerId → display data once; cursor messages carry just from to keep bytes minimal. |
| Game state ticks (10+ Hz) | OFF | Same — render from a cached peer table you populate on presence.joined. |
| WebRTC SDP/ICE | OFF (direct messages already stamp) | Use send instead of channel publishes for these. |
What's a good shape for peerMetadata?
Keep it small (≤1 KB serialized) and focused on what every consumer of presence/direct/messages needs.
Good:
{
"userId": "u_alice_123",
"username": "Alice Anderson",
"profilePic": "https://cdn.example.com/u/alice.jpg",
"role": "presenter"
}
Bad (too much, mostly only useful to the connecting user):
{
"userId": "u_alice_123",
"username": "Alice Anderson",
"profilePic": "https://cdn.example.com/u/alice.jpg",
"email": "alice@example.com", // ← do other peers need this?
"phone": "+1-555-0100", // ← probably not
"subscriptionTier": "enterprise", // ← internal state
"billingAccountId": "acc_xyz", // ← internal state
"abTestVariants": { ... } // ← internal state
}
The 4 things you typically want stamped onto other peers' views:
- Stable user ID — for client-side
peerId → userjoins - Display name — to render
"Alice typed..." - Avatar URL — to render the user's bubble
- Role / status tag —
presenter/moderator/onlineetc.
If you need more, fetch it from your backend keyed by the stable user ID. Don't bloat peerMetadata.
Size limits
- Per-peer
peerMetadatais bounded by the JWT size envelope (same asmetadata). - The server hard-caps the serialized
peerMetadatafield at 4 KB during JWT verify. Larger claims are rejected withinvalid_token.
Worked example: chat with rich identity
A classroom chat where every message renders with the sender's name, avatar, and role tag.
const token = jwt.sign({
sub: user.id,
exp: Math.floor(Date.now()/1000) + 3600,
channels: [`class-${classId}`],
permissions: ["publish", "subscribe", "presence", "send"],
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 } });
ws.send(JSON.stringify({
type: "subscribe",
channel: `class-${classId}`,
includeSenderMetadata: true,
requestId: "sub-1",
}));
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === "message") {
// Every message arrives already stamped — no backend lookup needed.
renderChatBubble({
author: msg.fromMetadata?.username ?? msg.from,
avatar: msg.fromMetadata?.profilePic,
role: msg.fromMetadata?.role,
text: msg.data.text,
});
}
if (msg.type === "presence") {
// Update the participant list using the same metadata shape.
for (const peer of msg.joined) addToRoster(peer.peerId, peer.metadata);
for (const peer of msg.left) removeFromRoster(peer.peerId);
}
});
This pattern is documented end-to-end in the Live Presence & Chat guide.
Worked example: high-frequency cursor sync (metadata via presence, not per-message)
A Figma-like collaborative editor where cursors update at ~30 Hz per peer.
const token = jwt.sign({
sub: user.id,
exp: ...,
channels: [`doc-${docId}`],
peerMetadata: {
userId: user.id,
username: user.displayName,
cursorColor: user.cursorColor,
},
}, ...);
const peerRoster = new Map(); // peerId → { username, cursorColor }
ws.send(JSON.stringify({
type: "subscribe",
channel: `doc-${docId}`,
// includeSenderMetadata omitted → defaults to false
requestId: "sub-1",
}));
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === "presence") {
for (const peer of msg.joined) peerRoster.set(peer.peerId, peer.metadata);
for (const peer of msg.left) peerRoster.delete(peer.peerId);
}
if (msg.type === "message" && msg.data.kind === "cursor") {
const peer = peerRoster.get(msg.from);
if (!peer) return; // they left before we got their cursor
renderCursor(peer.cursorColor, peer.username, msg.data.x, msg.data.y);
}
});
The chat message above is ~50 bytes per cursor update without metadata stamping vs ~250 bytes with — at 30 Hz × 20 peers × hours of session, the savings are real.
This pattern is documented end-to-end in the Collaborative Apps guide.
Cheat sheet
| You want to ship X | Use |
|---|---|
| TURN ICE servers to the browser | metadata.iceServers in the JWT — read from welcome.metadata.iceServers |
| The user's display name + avatar that every peer should see | peerMetadata.{ userId, username, profilePic } |
| Per-message identity for chat (every message renders standalone) | Subscribe with includeSenderMetadata: true |
| Per-peer identity for high-frequency events (cursors / game state) | Don't subscribe with includeSenderMetadata. Cache from presence events instead. |
| Sender identity on direct (WebRTC SDP) messages | Always on — no opt-in. Use direct.fromMetadata. |
| Feature flags scoped to the connecting user | metadata.featureFlags in the JWT |
| Recording / moderation toggle for this session | metadata.recordingEnabled in the JWT |
What's next
- WebRTC Signalling guide — the marquee end-to-end pattern with TURN ICE servers in the JWT
- Live Presence & Chat guide — the chat pattern in full
- Collaborative Apps guide — the high-throughput cursor pattern