Showing who's online without your own heartbeat table
A presence channel maintains the roster for you. The three calls, the join/leave events, and why a heartbeat table is the wrong shape for this problem.
The homegrown version of “who’s online” is a last_seen_at column, a heartbeat every thirty seconds and a cleanup job — and it’s wrong in a way that’s hard to fix: a user who closes their laptop stays online until the timeout expires. Infrai’s presence channels maintain the roster from the connection itself. POST /v1/realtime/channel/create with type: "presence", POST /v1/realtime/token/issue for each client, and GET /v1/realtime/presence/get/{channel} to read who’s there.
No heartbeats, no cleanup job, and a disconnect is a presence.leave event rather than a timeout you’re waiting on.
Create the channel
curl -sS -X POST "https://api.infrai.cc/v1/realtime/channel/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"channel": "workspace:clinic-east", "type": "presence"}'
{
"ok": true,
"data": {
"channel_id": "chn_8320a6cf09f3426885646a",
"name": "workspace:clinic-east",
"type": "presence",
"vendor": "tencent_im",
"created_at": "2026-08-25T19:21:29Z",
"member_count": 0,
"last_published_at": null
}
}
Three channel types exist and the difference matters. public lets anyone with the name subscribe; private requires a capability token; presence is private plus the member roster and join/leave events. Pick presence only when you actually want the roster — it’s the most expensive of the three in terms of what the fan-out layer has to track.
member_count comes back on every read, which is often the only number a UI needs.
Issue a token per client
Clients never hold your API key. They get a short-lived token scoped to the channels they may touch:
curl -sS -X POST "https://api.infrai.cc/v1/realtime/token/issue" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"client_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
"channels": ["workspace:clinic-east"],
"capabilities": ["subscribe", "presence"],
"ttl_seconds": 3600
}'
{
"ok": true,
"data": {
"token": "rtt_9wQ1zV6pLkS3dHyBnMfE",
"jti": "jti_4kQ9mVzR1sXbNt",
"channels": ["workspace:clinic-east"],
"capabilities": ["subscribe", "presence"],
"expires_at": "2026-09-21T04:06:57Z"
}
}
capabilities is a closed set: subscribe, publish, presence and history. A client that only needs to see the roster and receive updates gets subscribe and presence — not publish, because a viewer who can publish is a viewer who can impersonate the server.
Keep ttl_seconds short. An hour is generous for a web session, and the jti gives you something to revoke if you need to cut one client off before it expires.
Read the roster
curl -sS "https://api.infrai.cc/v1/realtime/presence/get/workspace:clinic-east" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The response carries channel, members and a next_cursor for large rosters. That’s your “3 people viewing” indicator without a single heartbeat.
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const headers = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
export async function ensurePresenceChannel(name) {
const res = await fetch(`${API}/v1/realtime/channel/create`, {
method: "POST",
headers,
body: JSON.stringify({ channel: name, type: "presence" }),
});
const payload = await res.json();
// A channel that already exists is success for this purpose — the caller wants
// it to exist, not to be the one who made it.
if (!payload.ok && payload.error?.code !== "REALTIME_CHANNEL_EXISTS") {
throw new Error(payload.error?.code ?? "channel_create_failed");
}
return name;
}
export async function tokenForViewer(userId, channel) {
const res = await fetch(`${API}/v1/realtime/token/issue`, {
method: "POST",
headers,
body: JSON.stringify({
client_id: userId,
channels: [channel],
capabilities: ["subscribe", "presence"],
ttl_seconds: 3600,
}),
});
const payload = await res.json();
if (!payload.ok) throw new Error(payload.error?.code ?? "token_failed");
return { token: payload.data.token, expiresAt: payload.data.expires_at, jti: payload.data.jti };
}
export async function roster(channel) {
const res = await fetch(`${API}/v1/realtime/presence/get/${encodeURIComponent(channel)}`, { headers });
const { data } = await res.json();
return { count: (data.members ?? []).length, members: data.members ?? [] };
}
The events you can subscribe to
curl -sS "https://api.infrai.cc/v1/realtime/event/types" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"types": ["channel.closed", "channel.opened", "message.published", "presence.join", "presence.leave"]
}
}
presence.join and presence.leave are what replace the heartbeat entirely. Your client updates the avatar list on an event rather than polling, and the server-side view is GET /v1/realtime/presence/get/{channel} whenever you need the authoritative answer.
Why the heartbeat table loses
| Concern | last_seen_at column | presence channel |
|---|---|---|
| Detecting a closed tab | after the timeout | on disconnect |
| Write load | every client, every 30s | none |
| Cleanup job | yes | no |
| Accuracy of the count | approximate | the roster |
| Cost | database writes forever | per publish, when you publish |
The write load is the part people underestimate. A thousand concurrent users heartbeating every thirty seconds is around 33 writes a second against a table whose only purpose is to be overwritten.
Limitations worth knowing
Presence tells you who is connected, not who is active. A user with the tab open and a phone call in progress shows as present, so if your product needs idle detection, that’s still your own client-side logic on top.
The fan-out vendor today is tencent_im, with Ably and Pusher listed as pending, so a region where that vendor is weak is a real consideration — check GET /v1/discovery/realtime.publish for the current readiness rather than assuming. And there’s no client SDK here: the connection is handled by the vendor’s own client library with the token you issued, which means the browser-side code isn’t as tidy as Pusher’s or Ably’s. If your product is a collaborative editor where presence and cursors are the feature, Liveblocks or Ably will be a better fit and it isn’t close.
What’s hard to copy is the second question. The roster update that also needs an email, a queued job and a metric doesn’t need another vendor: POST /v1/realtime/publish to fan out, POST /v1/queue/publish for the follow-up work, POST /v1/email/send for the notification to whoever is offline — same key, one bill, one usage view in GET /v1/account/usage.
Channel and token management report billing_class: free in discovery; publishing is the billable part at a live rate you can read from GET /v1/discovery/realtime.publish (verified 2026-09-21), and those rates trend downward as vendor contracts improve.