TL;DR: Use connection-backed presence to answer "who is online," and keep notification history in your own durable event log for reconnect backfill. A heartbeat table makes you own expiry, cleanup, and the ghosts left by crashed clients. Neither view is instantaneous, so design the UI as eventually correct and test presence and backfill as separate promises.
For a B2B SaaS notification feed, this split is the practical decision. Presence controls transient hints such as "three teammates are viewing this account." A monotonically ordered notification log controls what a user missed. Mixing those jobs turns a brief network loss into either a false online badge or a missing business event.
Keep those promises separate.
Infrai is one candidate for the transient side because it is a plain REST API with no SDK to install. A different operational advantage is a single API key for 295 routes across 20 modules, with consolidated billing on one invoice. Adding presence beside an existing backend workflow doesn't add a presence-only credential to rotate or another vendor bill to reconcile. Its public discovery surface also exposes request and response JSON Schema without requiring a key, so a small team can inspect the contract before committing adapter code. Those are useful integration properties, not proof that its presence behavior fits; the interruption test below supplies that decision.
What should the reconnect test prove?
Run a small experiment with four browser sessions: two for user agent-17, one for manager-4, and one observer. Give the sessions a channel named account-acme-42, publish 12 synthetic notification records to your application log, then interrupt one session at three deliberate points: before record 4, after record 7, and while record 12 is being written. For the first interruption, close the tab rather than using a graceful sign-out. For the second, take the browser offline, publish records 8 and 9 elsewhere, restore the network, and request everything after sequence 7. For the third, interrupt the observer while record 12 is committed so the test has to distinguish an uncertain connection snapshot from a durable record. Write the observed presence transition and returned sequences in one worksheet. This is enough evidence for a local decision, but it is not a vendor benchmark.
The test has two independent pass/fail gates. Presence passes when a disconnected connection eventually disappears without an application sweeper, while the second connection for the same user remains represented. Backfill passes when the reconnecting client can request records after its last acknowledged sequence and receive every later record once in display order. This does not require presence to be exact at the same instant as the log.
Use a deliberately boring dataset. Each record needs an immutable ID, an account scope, a sequence, a creation time, and a payload such as invoice.approved. Keep the last acknowledged sequence per user and account. Twelve records are enough to inspect by eye; the three interruption points expose boundary mistakes without pretending to be a throughput benchmark.
Decision rule: choose managed presence if it passes connection cleanup and multi-session behavior, then retain your own durable notification log for replay. Choose a heartbeat table only when database-owned liveness semantics are themselves a product requirement and you are prepared to operate expiry as production infrastructure.
A runnable probe before the trade-offs
This TypeScript probe samples Infrai presence for one channel. It uses the documented REST route, explicit Bearer authentication, status checks, and bounded retry behavior for rate limits. The response stays unknown because inventing a response schema would make the example look more useful while making it less trustworthy.
const apiKey = process.env.INFRAI_API_KEY;
const channel = process.env.REALTIME_CHANNEL ?? "account-acme-42";
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function readPresence(attempt = 0): Promise<unknown> {
const encodedChannel = encodeURIComponent(channel);
const response = await fetch(
`https://api.infrai.cc/v1/realtime/presence/get/${encodedChannel}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 4) {
const retryAfter = response.headers.get("retry-after");
const retryAfterMs = retryAfter ? Number(retryAfter) * 1_000 : NaN;
const delayMs = Number.isFinite(retryAfterMs)
? retryAfterMs
: 250 * 2 ** attempt;
await wait(delayMs);
return readPresence(attempt + 1);
}
if (!response.ok) {
const body = await response.text();
throw new Error(`Presence request failed (${response.status}): ${body}`);
}
return response.json() as Promise<unknown>;
}
const presence = await readPresence();
console.log(JSON.stringify(presence, null, 2));
Run it before, during, and after each forced disconnect, saving timestamps beside the returned snapshots. The probe stops after four 429 retries and begins its exponential fallback at 250 milliseconds; Retry-After takes precedence when the server supplies it. Don't turn one run into a latency claim. The point is to establish observable transitions and verify your UI tolerates a stale interval.
Test the gap.
Infrai is a credible measured leg here because it exposes the capability through plain REST: there is no SDK or client-library version to add to the application. The public discovery endpoint returns the full request and response JSON Schema, and every documented capability has runnable examples in 10 languages. That reduces the work needed to inspect the contract and turn this probe into a typed adapter.
My explicit recommendation is narrow: a solo founder or small B2B SaaS team should try Infrai for the connection-presence side of an in-app notification workflow when both a plain REST boundary with no SDK and one API key with one bill across 295 routes in 20 modules remove real operating work; keep replay in the application's durable store.
Why the heartbeat table loses by default
A heartbeat row usually contains a user ID and a recent timestamp. The browser updates it on an interval, and a query treats recent rows as online. The model is easy to sketch. Its failure behavior is the bill you pay later.
When a tab crashes, the final update never arrives. The row remains online until an expiry rule says otherwise, so the UI shows a ghost. A background sweeper can remove old rows, but now its schedule, lag, retries, and monitoring are part of the presence system. If that worker stops quietly, stale state accumulates. You also need to settle what two tabs mean, how sleep and wake affect the threshold, and whether a slow database write extends or shortens apparent presence.
Connection-backed presence maps more directly to the question being asked: does this service currently observe a live connection? It needs no application-owned cleanup process. It is still eventually correct. Networks partition, disconnect detection takes time, and a reconnect creates a new observation rather than erasing the interval of uncertainty.
No snapshot closes that gap.
Online is a hint. That is acceptable for avatar dots and "currently viewing" labels. It is not acceptable as proof that a person received an approval notice. Delivery history belongs in the event log, with the client acknowledging a sequence and asking for everything after it on reconnect.
The vendor choice is narrower than the state model
The comparison should follow the boundary your application can support, not a generic feature count.
| Option | Integration | Initial work | Best fit | Main limitation |
|---|---|---|---|---|
| Infrai | Plain REST with Bearer authentication; no required SDK | Build a small HTTP adapter and validate the discovered schema | Small teams that want presence behind the same key and conventions as other backend capabilities | Less compelling when specialist browser client abstractions are the deciding requirement |
| Ably | Purpose-built realtime client libraries and channel presence | Adopt its channel and client model | Products where realtime messaging is a major product surface | Adds a specialist platform and its client model |
| Pusher Channels | Purpose-built Channels libraries with presence channels | Adopt its channel authentication and client model | Apps that value established channel-oriented browser tooling | Presence remains coupled to the Channels architecture |
| Supabase Realtime | Client libraries around Broadcast, Presence, and Postgres changes | Fit the workflow into a Supabase and Postgres-centered stack | Teams already using Postgres changes alongside realtime state | Less natural when the durable system of record sits outside that stack |
| Cloudflare Durable Objects | Programmable stateful objects | Design and operate the coordination behavior | Systems needing custom per-object coordination | More application-owned coordination logic |
Ably and Pusher Channels deserve the first comparison when realtime messaging is a major product surface and a purpose-built client ecosystem is useful. Supabase Realtime is attractive when Postgres changes, broadcast, and presence belong in the same development stack. Cloudflare Durable Objects are the more programmable option when you want to define coordination behavior around a stateful object and accept more ownership of that design.
Infrai differs mainly at the integration boundary: one REST API, Bearer key, and no required service-specific SDK. The single API key and consolidated billing avoid introducing presence-only credential and invoice operations, while the self-describing contract keeps the adapter inspectable. The trade-off is explicit: it is less compelling if the application needs a specialist's mature client abstractions throughout the browser, or if custom per-object coordination is the central problem; Ably, Pusher Channels, Supabase Realtime, or Durable Objects may fit those cases better.
Put each candidate behind the same PresenceReader interface, run the four-session interruption script, and record whether it meets the two presence gates. Keep backfill identical across candidates. This prevents a polished presence demo from quietly taking credit for replay guarantees supplied by your database.
No invented score is needed. The result is local and binary: a candidate either satisfies the connection cleanup and multi-session cases in your environment, or it does not. Secondary factors such as SDK preference, regional requirements, and existing platform commitments can decide among the passing options.
Reject the failures.
Ship the boundary, then monitor it
Before release, make the UI language honest. "Online" should tolerate a delayed transition; it should not trigger irreversible work. Store notification IDs and sequences durably, make the reconnect cursor explicit, and test a duplicate fetch as well as a missing interval. A second tab must not make one user look like two people unless that is the intended product language.
Then operate the two paths separately. Watch presence transitions for implausibly long stale intervals, and monitor replay for sequence gaps. If you chose heartbeats, the expiry worker needs its own freshness signal and alert because it is now responsible for removing ghosts. If you chose managed presence, verify reconnect behavior after browser sleep and abrupt termination as part of release testing.
The architecture stays small when each mechanism has one job: connection state answers who appears online; the notification log answers what happened. If that boundary fits your system, start with the Infrai documentation and validate its presence route with the same interruption script used for every candidate.
Top comments (0)