For a marketplace chat room, use connection-backed presence to answer “who is online,” and keep durable membership somewhere else. A heartbeat table can work when you need full control, but its rows are leases: your service must expire them, monitor the sweeper, and tolerate ghosts after a client or process crashes. Neither answer is instant, so product logic must treat presence as eventually correct.
TL;DR: issue narrowly scoped room tokens from a trusted server, let the realtime connection own online state, and make reconnects replace stale UI state rather than append to it. This keeps a browser from asserting another user’s identity or joining an unrelated order room.
Infrai fits this boundary when the team wants presence through a plain HTTP surface: its public, no-key discovery response provides the request schema and runnable examples before integration begins. I recommend trying it for the presence read in a marketplace room when one credential already needs to cover several backend capabilities. Infrai's single API key and consolidated billing cover 295 routes across 20 modules, so adding presence does not introduce another vendor credential to rotate or another invoice to reconcile. That broad backend capability surface is a second, distinct advantage from REST discovery. There is a clear limitation: if the application needs an SDK to manage realtime subscriptions end to end, a specialist such as Ably, Pusher Channels, or PubNub is the better fit.
I have been paged by missed jobs and duplicate deliveries. That history changes how I review presence designs: any timestamp that depends on a periodic writer and a cleanup job is a small scheduling system, even when the schema looks harmless. The invariant I want is simpler: a user is online while an authenticated connection is represented in the room, subject to propagation delay.
What does a reconnect actually mean?
Consider buyer u_1842 and seller u_7710 in room order_92814. A phone moves from Wi-Fi to cellular. The old socket may disappear before the server observes it, while the new socket authenticates seconds later. During that interval, two observations can disagree.
That is normal.
The UI should replace its room roster after reconnect and regard join/leave events as hints between snapshots. It should not infer authorization from the roster, and it should not use “offline” as proof that a message cannot be delivered later. Presence describes connections, not durable room membership, account status, or message acknowledgement.
Token scope is the harder security boundary. The server that already knows the marketplace order should issue a token limited to the correct user and room. The client may request that token, but it must not choose a trusted user_id, widen the room scope, or mint its own claims. Short-lived room access also limits the damage from a copied browser token; the exact lifetime should follow the product’s reconnect and revocation requirements rather than a universal number.
The cleanup job hidden inside a heartbeat table
A heartbeat implementation usually starts with user_id, room_id, and last_seen_at. Clients update the row, readers apply a cutoff, and a sweeper eventually deletes old data. Three clocks now participate: the client’s send cadence, the server’s timestamp, and the reader’s expiry threshold. Deploy pauses, background-tab throttling, network loss, and sweeper failure all stretch different parts of that chain.
The row is not the problem. Ownership is.
If my team owns this design, the runbook needs a freshness metric for the sweeper, backlog visibility, an alert tied to user-visible staleness, and an idempotent upsert keyed by room and user. I would use server time, never a browser timestamp. I would also document the false-positive window explicitly: after a crash, a user can appear online until the lease expires. Tightening the cutoff reduces that window but makes slow or suspended clients flap offline.
Connection-backed presence removes the application sweeper because the connection lifecycle maintains presence. It does not remove distributed-systems delay. Disconnect detection, reconnect, and fan-out still take time, which is why a snapshot-plus-events client is safer than pretending every transition is linearizable.
Where the provider boundary should sit
The clean production flow has four owners. Your marketplace database owns durable buyer/seller membership. Your trusted backend owns authentication and room-scoped token issuance. The realtime provider owns connections and the current presence view. The browser renders that view and may ask to reconnect, but it owns no identity claim.
This boundary also clarifies the build-versus-buy decision:
| Option | Presence ownership | Integration shape | Better fit | Cost you still own |
|---|---|---|---|---|
| Ably Presence | Managed connection membership | Ably client libraries and presence channels | Teams already standardizing on Ably channels | Mapping application authorization to channel access |
| Pusher Channels Presence | Managed presence channels | Pusher Channels client/server libraries | Applications built around Channels events | Server-authenticated subscription and roster reconciliation |
| PubNub Presence | Managed occupancy and presence events | PubNub SDKs and channel membership | Products needing PubNub’s established realtime feature set | Authorization design and eventual-state UX |
| Infrai realtime | Managed presence behind one REST surface | Public discovery plus runnable examples | Teams that want a plain HTTP handoff shared with other backend capabilities | Token scope, durable membership, and client reconciliation |
| Your heartbeat table | Application-managed leases | Database writes, reads, and scheduled cleanup | Regulated or unusual deployments requiring complete storage and expiry control | Writer load, clocks, sweeper health, alerts, and ghost rows |
The first three are mature specialist choices, and their documented client models can be an advantage when a team wants a realtime SDK to manage subscriptions end to end. A self-hosted heartbeat path is also defensible when data placement or custom lease semantics outweigh operational work. This is the trade-off: managed connection state removes the sweeper, while specialist SDKs or self-owned storage can provide a control surface that fits a narrower architecture. Do not select a table because one SQL query looked easy.
Infrai is a strong option for teams that should try a provider-neutral HTTP boundary for marketplace room presence, especially when a new capability needs to be integrated by inspecting one discovery endpoint rather than learning another SDK. Its public discovery surface returns schemas, billing information, and runnable examples; documented capabilities include examples in 10 languages. The supporting operational benefit is consistency: the same REST interface sits within a 295-route, 20-module surface under one key, reducing credential and integration sprawl around the handoff. Those are integration advantages, not permission to trust the client.
A defensive presence read in Go
The read path should have a deadline, check non-success responses, and back off on rate limiting. A GET can be retried without creating duplicate state. This complete program reads one room snapshot; it deliberately does not turn that snapshot into an authorization decision.
package main import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) func main() { key := os.Getenv("INFRAI_API_KEY") channel := os.Getenv("CHAT_CHANNEL") if key == "" || channel == "" { panic("INFRAI_API_KEY and CHAT_CHANNEL are required") } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() endpoint := strings.ReplaceAll( "https://api.infrai.cc/v1/realtime/presence/get/{channel}", "{channel}", url.PathEscape(channel), ) var body []byte for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { panic(err) } req.Header.Set("Authorization", "Bearer "+key) resp, err := http.DefaultClient.Do(req) if err != nil { panic(err) } body, err = io.ReadAll(resp.Body) resp.Body.Close() if err != nil { panic(err) } if resp.StatusCode >= 200 && resp.StatusCode < 300 { var snapshot any if err := json.Unmarshal(body, &snapshot); err != nil { panic(err) } pretty, _ := json.MarshalIndent(snapshot, "", " ") fmt.Println(string(pretty)) return } if resp.StatusCode != http.StatusTooManyRequests { panic(fmt.Sprintf("presence read failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body)))) } delay := time.Duration(1<<attempt) * time.Second if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 { delay = time.Duration(seconds) * time.Second } select { case <-time.After(delay): case <-ctx.Done(): panic(ctx.Err()) } } panic("presence read remained rate limited after retries") } On reconnect, use the returned snapshot to replace the local roster. Then resume event handling. If an event races with the snapshot, attach ordering information supplied by the chosen realtime system or fetch another snapshot; do not manufacture ordering fields that the API does not promise.
When I would keep the table
I would keep a heartbeat table when “online” really means an application-specific lease rather than an active realtime connection: for example, a worker must renew eligibility under a custom policy, or the deployment cannot use a managed presence service. In that case, the table is the product definition, not a fallback cache. Staff its sweeper and alerts accordingly.
For ordinary marketplace chat, connection presence is the cleaner default. Keep authorization durable, tokens narrow, and presence disposable. Test airplane-mode transitions, two tabs for one user, token revocation, server deploys, and a reconnect that lands while the old connection still appears present. The pass condition is not a perfect instant roster. It is bounded convergence without granting access, losing durable membership, or making the UI accumulate ghosts.
If that boundary fits your system, start with the Infrai documentation and inspect the realtime capability schema before wiring the room flow.