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

Concernlast_seen_at columnpresence channel
Detecting a closed tabafter the timeouton disconnect
Write loadevery client, every 30snone
Cleanup jobyesno
Accuracy of the countapproximatethe roster
Costdatabase writes foreverper 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.

References

Browse more realtime developer guides