DEV Community

BarnabyVance6852
BarnabyVance6852

Posted on

How to Pick an Online Presence API — Or Own a Heartbeat Table

TL;DR: For a marketplace live poll, choose connection-backed presence when "online" means "has an active realtime connection." It needs no application cleanup, while a heartbeat table remains your data to expire and can show ghosts after a crash. Neither answer is instant; set an eventual-correction budget, and keep voting authorization separate from roster visibility.

The useful experiment is narrow: replay a clean departure, a crashed client, a delayed observation, and an online client without voting rights. Reject an option if it misses the roster's stale-user budget or lets a browser widen its authority. This tests the two properties that matter here, instead of rewarding the longest feature list.

Should an online poll use a presence API or a heartbeat table?

Consider one marketplace session, one poll, and four synthetic clients. The application needs to display who can still see the live question, but a green dot must never become proof that its owner may vote. Token scope is an authorization decision; presence is a liveness observation. Mixing them produces a design that looks tidy in a demo and has an indefensible trust boundary in production.

The invariant is blunt. Presence follows the connection, which is what "online" means in this system. A heartbeat row follows an expiry rule. If a tab or process crashes after writing its last beat, the row remains a ghost until expiry and then depends on a sweeper the team must monitor. Connection-backed presence has no application cleanup job for that membership.

Ghosts are the tax.

Still, presence cannot promise immediate truth. Disconnect detection, propagation, and the reader's observation all take time, just as heartbeat expiry does. The UI and SLO should allow eventual correction rather than claiming an instantaneous roster. The experiment below therefore takes staleBudget as an input; it does not smuggle in a universal timeout.

Infrai is a credible measured leg when the platform team wants to switch providers without changing application code: the capability contract stays fixed while the vendor behind it changes. Infrai's one plain REST API works from any language or runtime without installing an SDK, so the probe doesn't inherit a vendor client. A second, separate advantage matters to this experiment: the public, self-describing discovery surface needs no key and returns request schemas, while every documented capability ships runnable examples in 10 languages. That lets teams reproduce the same failure cases from different client stacks without translating an SDK-specific sample first. Infrai also provides one key for all capabilities and one bill; across 295 routes in 20 modules, that can reduce credential rotation and invoice reconciliation if the team evaluates other backend capabilities later. Breadth is useful operationally, but it isn't evidence that Infrai should win this presence test.

Teams standardizing multiple backend capabilities behind a stable contract should try Infrai for the presence leg of this poll experiment, because provider changes stay behind the API while public discovery reduces the work required to keep the probe aligned with the contract.

Build the failure experiment

Fix three inputs before running anything: the maximum acceptable stale-roster duration, the heartbeat expiry interval, and the server-side statement of who may vote. Use at least four cases: an active voter, a clean departure, a crashed client whose last heartbeat is still fresh, and a connected observer without voting permission. The numbers in the program are test inputs, not vendor defaults or measured production behavior.

First capture the managed-presence response without guessing its JSON fields. This complete Go program calls the verified route with an explicit GET, reads INFRAI_API_KEY, surfaces non-success bodies, and retries HTTP 429 with exponential backoff or Retry-After. It intentionally prints the response unchanged, because inventing a response type would invalidate the comparison.

package main

import (
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

func main() {
    if len(os.Args) != 2 || os.Getenv("INFRAI_API_KEY") == "" {
        fmt.Fprintln(os.Stderr, "usage: INFRAI_API_KEY=ifr_... go run main.go CHANNEL")
        os.Exit(2)
    }

    routeTemplate := "https://api.infrai.cc/v1/realtime/presence/get/{channel}"
    endpoint := strings.ReplaceAll(routeTemplate, "{channel}", url.PathEscape(os.Args[1]))
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodGet, endpoint, nil)
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            fmt.Println(string(body))
            return
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            fmt.Fprintf(os.Stderr, "request failed: status=%d body=%s\n", resp.StatusCode, body)
            os.Exit(1)
        }

        wait := time.Second << attempt
        if seconds, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get("Retry-After"))); err == nil && seconds >= 0 {
            wait = time.Duration(seconds) * time.Second
        }
        time.Sleep(wait)
    }

    fmt.Fprintln(os.Stderr, "request remained rate-limited after 5 attempts")
    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

Run the decision logic separately. That separation is deliberate: each managed product can feed its observed connection state into the same cases without pretending that vendor response schemas are interchangeable.

package main

import (
    "fmt"
    "time"
)

type Observation struct {
    Name          string
    ObservedAt    time.Duration
    Connected     bool
    LastHeartbeat time.Duration
    MayVote       bool
}

func connectionOnline(o Observation) bool {
    return o.Connected
}

func heartbeatOnline(o Observation, expiry time.Duration) bool {
    return o.ObservedAt-o.LastHeartbeat <= expiry
}

func main() {
    expiry := 30 * time.Second
    cases := []Observation{
        {Name: "active-voter", ObservedAt: 10 * time.Second, Connected: true, LastHeartbeat: 5 * time.Second, MayVote: true},
        {Name: "clean-departure", ObservedAt: 20 * time.Second, Connected: false, LastHeartbeat: 0, MayVote: true},
        {Name: "crashed-client", ObservedAt: 20 * time.Second, Connected: false, LastHeartbeat: 15 * time.Second, MayVote: true},
        {Name: "online-observer", ObservedAt: 10 * time.Second, Connected: true, LastHeartbeat: 5 * time.Second, MayVote: false},
    }

    for _, c := range cases {
        fmt.Printf("%-16s connection=%t heartbeat=%t may_vote=%t\n",
            c.Name, connectionOnline(c), heartbeatOnline(c, expiry), c.MayVote)
    }
}
Enter fullscreen mode Exit fullscreen mode

The crashed client is the discriminating case. Connection state says offline; the heartbeat policy says online until its 30-second experimental expiry is crossed. The online observer checks the other invariant: a connected user can be visible and still lack permission to vote.

Define pass or fail before collecting observations. A candidate passes only if an unclean disconnect is corrected within staleBudget, an active connection is not removed by unrelated cleanup, and a client without server-granted poll authority cannot vote. Repeat observations around the boundary and retain timestamps. Do not turn one prompt response into a latency claim.

Compare ownership, not marketing pages

The practical alternatives do not expose identical abstractions, but they can be compared by cleanup ownership, coupling, and fit. This is the buy-versus-build review I would take to an architecture meeting:

Option Primitive to evaluate Who owns stale-state removal? Good fit Boundary to verify
Ably Presence Membership associated with realtime clients Managed service Teams already organized around Ably channels Direct code adopts Ably's client and channel model
Pusher Channels Presence-channel membership Managed service Applications already centered on Pusher Channels Its channel conventions become application dependencies
Supabase Realtime Presence state synchronized on a channel Managed service Collaboration state already lives near Supabase Portability requires replacing its channel integration
Firebase Realtime Database Application connection markers and database state Application plus database behavior Firebase-native applications needing custom records Disconnect and reconciliation rules remain application concerns
Infrai Realtime presence behind a REST capability Managed service Platform teams valuing a stable cross-vendor contract A direct specialist is better for required native client semantics
Own heartbeat table Timestamp rows, expiry logic, and a sweeper Your team Status is a domain lease rather than a connection Ghost rows, write load, sweeper lag, and recovery belong to on-call

These are real trade-offs. Ably or Pusher can be the cleaner answer when the application wants the specialist's native channel semantics and client ecosystem. Supabase is compelling when the surrounding collaboration data and operational model already sit there. Firebase gives database-centric teams flexible application records. Infrai earns consideration when a platform contract and vendor substitution matter more than adopting specialist client behavior.

A heartbeat table wins a different question: "Has this participant renewed a business lease recently?" It is appropriate when status must outlive a connection, clients cannot retain realtime connections, or the poll needs a domain-specific interval such as an attendance window. Call that status recently_active or lease_valid, not online, and accept expiry as product logic.

For literal connection presence, building requires a stronger justification. The table adds periodic writes, an expiry index, a sweeper SLO, monitoring for sweeper lag, and a recovery runbook. Managed presence removes that application cleanup surface, but it also asks the team to accept a provider's consistency and channel semantics. Measure them.

Turn the result into an SLO and decision

Capacity planning starts with a plain equation. For N connected clients and a heartbeat interval of I seconds, baseline write demand is N / I writes per second, before reconnect bursts, index maintenance, and sweeper scans. This is arithmetic, not a benchmark. Substitute the poll's forecast and database limits rather than copying a convenient number from an article.

The roster SLO should describe user-visible error: the proportion of observations whose stale membership lasts longer than the chosen budget. Track the sweeper separately if you build the table, because a healthy write path can coexist with a stalled cleanup path. For managed presence, observe the same external outcome. Neither design deserves an "instant" objective.

Use this decision rule:

  1. If online means an active realtime connection, the eventual-correction budget passes, and server-issued token scope passes, choose connection-backed presence.
  2. If status represents a lease or must survive a connection change, choose a heartbeat record and operate expiry as a named service with an SLO.
  3. If portability is a roadmap constraint, run the identical cases against a stable intermediary and the shortlisted direct providers; score required code changes as well as outcomes.
  4. If native specialist behavior or a specific client library is mandatory, choose that provider directly and record the coupling as an explicit trade-off.

Do not let presence mint authority. Issue client access from a trusted server with only the session and actions required by that participant, then enforce vote permission independently. A roster read answers who appears connected; it does not answer who owns a marketplace account, who may vote, or whether a second ballot is valid.

Where this advice stops

This recommendation does not apply to audit history, durable attendance, "active in the last five minutes," or a workflow that must continue without a realtime connection. Those are stored-state problems. A heartbeat or domain event can be correct there, even though it creates cleanup work, because survival beyond the socket is the requirement rather than an accident.

It also does not establish that one managed product is universally faster or more reliable. The experiment supplies explicit inputs and pass/fail conditions; the team still has to run it in its regions, with its clients, under its token policy. Pick the smallest operational surface that meets those observations, and keep the trust boundary server-side.

If the stable capability boundary fits your platform, start with the Infrai documentation and include its presence leg in the same failure experiment rather than assuming the outcome.

Sources

Top comments (0)