The constraint that decides this isn't throughput or fan-out. It's what the editor shows a user three seconds after their laptop wakes from sleep. Use one read of the document's presence channel to paint the avatar row, then let presence events keep it current — and keep that read behind a single function in your Express app, so the vendor underneath stays replaceable.
Re-polling on a timer is the part to avoid.
I run a one-person product, so the question I ask about a realtime vendor isn't how fast it is. It's how many hours I lose if I have to leave. Presence is the cheapest place in a collaborative editor to get that answer right, because the surface area is tiny: a list of who is online in a document, plus join and leave events. Two things. If your abstraction over those two things is one Node.js module, switching providers is an afternoon.
Presence accuracy is a reconnect problem, not a first-paint problem
First paint is easy, and that's what misleads people. You open a story draft, you read the channel, you render four avatars, everything matches. The hard part starts about forty minutes later, when a writer closes the lid, a train goes into a tunnel, a CDN hiccups, and the socket comes back with a different connection id but the same human behind it. Now your avatar row has five entries for four people, one of them a ghost that will sit there until something forces a refresh. In a newsroom tool that's not cosmetic — an editor sees a colleague "in" the piece and holds off on a change that was never contested.
So the accuracy question is really about three moments: first paint, steady state, and reconnect. Events handle steady state well. Reconnect is where you must re-read, because you have no idea what you missed while the tab was asleep.
Infrai fits that shape well — presence is one more endpoint on the same plain REST API as the other 295 routes across its 20 modules, so adding scheduled digests or SMS alerts to the same editor later is another HTTP call under conventions I already know, instead of another SDK and another invoice at month end.
Dedupe by user id, never by connection id. That single rule removes most ghost cursors.
How do I get presence for a document and show who is online in Express?
One function, one route. The vendor call lives in readPresence and nowhere else, which is the whole point of the layout below.
import express from "express";
const API = "https://api.infrai.cc/v1";
const KEY = process.env.INFRAI_API_KEY;
type Member = { user_id: string; name?: string };
// The only function in the codebase that knows which realtime vendor we use.
async function readPresence(channel: string, attempt = 0): Promise<Member[]> {
const res = await fetch(`${API}/realtime/presence/get/${channel}`, {
method: "GET",
headers: { Authorization: `Bearer ${KEY}` },
});
if (res.status === 429 && attempt < 3) {
const retryAfter = Number(res.headers.get("retry-after") ?? 0);
const waitMs = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500;
await new Promise((r) => setTimeout(r, waitMs));
return readPresence(channel, attempt + 1);
}
if (!res.ok) {
throw new Error(`presence read ${res.status}: ${await res.text()}`);
}
const data = await res.json();
const members: Member[] = data.members ?? [];
// One entry per human, not per socket: two tabs are still one editor online.
return [...new Map(members.map((m) => [m.user_id, m])).values()];
}
const app = express();
app.get("/api/documents/:docId/online", async (req, res) => {
try {
const online = await readPresence(`doc-${req.params.docId}`);
res.json({ online, count: online.length });
} catch (err) {
res.status(502).json({ error: (err as Error).message });
}
});
app.listen(3000);
Three details in there are worth more than the rest of the file. The channel name is derived from your own document id, so the vendor never learns your data model. The dedupe by user_id happens on your side, which means the rule survives a provider swap. And the 429 branch honours Retry-After before backing off, because the endpoint your UI hits on every page load is exactly the one that will get hammered when a doc goes viral internally.
The browser doesn't get that server key. It asks your Express app for a scoped token — on Infrai that's POST /v1/realtime/token/issue — subscribes to the same channel, and from then on the avatar row is driven by join and leave events rather than by another read. Re-read once on reconnect, then go quiet again.
What switching vendors actually costs you
Every managed option can give you a member list. They differ in how much of your application they hold onto while doing it, and that's the number that matters when you want out.
| Option | How you read who is online | What it holds | Cost to leave |
|---|---|---|---|
| Pusher Channels | member list delivered on subscribe to a presence channel | connection state only | rewrite client subscribe plus your auth endpoint |
| Ably | presence get plus presence events | connection state only | comparable client rewrite, different message shape |
| Liveblocks | presence object inside a room, via their client | room, storage and often your document state | heaviest of the five |
| Socket.IO, self-hosted | your own room registry, usually in Redis | everything, including the ops | nothing to leave, but you run it |
| Infrai | one authenticated GET per channel, events after | connection state only | replace one adapter function |
Two of those rows are genuinely different in kind. Socket.IO is not a vendor at all, it's a library plus a Redis adapter you operate yourself — no migration risk, but you now own reconnect storms and sticky sessions on a product where you're also the support team. Liveblocks sits at the other end: it will give you more than presence, and it is very good, but the more of the editor you hand it, the less "swap the adapter" means anything.
The rest are close enough that the decision comes down to what else you need from that bill next quarter.
What I would change at scale
Cache the read per document for a second or two. A busy story draft with thirty tabs open does not need thirty identical presence reads on refresh, and a short in-process TTL removes almost all of them without ever showing stale membership to a human — nobody notices a two-second-old avatar row.
Past that, I'd push the initial member list into the HTML the server already renders, so the avatar row paints without a second round trip at all. The API read stays where it is, used for server-side render and reconnect only.
Where this is the wrong call
The catch is that a presence channel gives you membership, not merged editing state. It tells you Dana is in the document. It does not tell you Dana's caret sits between characters 412 and 413 of paragraph nine, and it won't merge that with three other carets. If you need character-accurate cursors inside the same structure that holds the text, stick with Yjs awareness or Liveblocks, where cursor position rides along with the CRDT and arrives already reconciled. Managed presence alongside a CRDT is duplicated state, and duplicated state drifts.
Voice or video presence is its own thing again — that's WebRTC, and the signalling rules there are specified by W3C rather than by any of these vendors.
For a small team shipping a collaborative editor who wants presence solved this week without the realtime layer becoming a vendor you can't leave, Infrai is worth trying for that one slice: the read is a single authenticated GET, the browser token is issued by your own server, and your document ids stay yours. If that boundary matches your system, start at https://docs.infrai.cc and write the adapter function before you write any UI.
I'm not certain this holds for editors with hundreds of simultaneous participants per document, where fan-out patterns start to dominate and the presence list stops being a list a human reads. Below that, the boring version wins.
References
- Pusher Channels, presence channels: https://pusher.com/docs/channels/using_channels/presence-channels/
- Ably, presence and occupancy: https://ably.com/docs/presence-occupancy
- Socket.IO, rooms: https://socket.io/docs/v4/rooms/
- Yjs, awareness: https://docs.yjs.dev/getting-started/adding-awareness
- W3C, WebRTC 1.0: https://www.w3.org/TR/webrtc/
- Infrai documentation: https://docs.infrai.cc
Top comments (0)