DEV Community

KenjiTanaka6849
KenjiTanaka6849

Posted on

Implementing Go Marketplace Signals — Presence Membership Explained with Concrete Limits

The page says that a marketplace conversation has 214 "active buyers," but sellers report that typing indicators linger and read receipts imply attention that never happened. Short answer: realtime presence is explained by connection state, and it cannot tell you who is paying attention. It can help decide whether to attempt a live delivery; it cannot prove that a person has read a message or is allowed to join the conversation.

The least complex safe design keeps three facts separate: connection membership, short-lived typing activity, and a durable server-accepted read position. A dropped connection may take time to reap, so a brief ghost is normal. Authorization remains a server-side decision every time.

Connection is not attention.

Why did the on-call page fire?

Start at the symptom. Suppose an illustrative alert fires when more than 5% of typing indicators remain visible for 15 seconds after the last typing event. The seller sees a buyer apparently composing a reply; the buyer has already closed a laptop. Presence still lists the connection until its loss is detected and reaped.

The faulty chain is easy to miss: connected = present, then present = attentive, then attentive = typing. The tempting first assumption is that one membership callback can drive all three states; the correction is to give each claim its own evidence and lifetime. Only the first equality is defensible, and even it has detection lag. The page fires late because it watches the UI artifact instead of the earlier disagreement between the last activity timestamp and the membership signal.

This is the postmortem sentence I would want: the system promoted a transport observation into a user-intent claim. No vendor can repair that data model for us.

What can realtime presence membership tell you, and what cannot be explained?

Use a small state machine. Presence may open or close the window in which live updates are useful, but typing expires on its own timer. A read receipt advances only after an authenticated client reports a message position and the server verifies access to that conversation.

Signal It can support It must not claim Storage shape
Presence membership A connection is currently listed Attention, identity proof, or permission Ephemeral
Typing event Recent composing activity Continued typing after its deadline Ephemeral with expiry
Read position Server accepted a position for an authorized user That the user understood the message Durable, monotonic

That separation gives retries a clean boundary. A repeated typing event refreshes the same expiry. A repeated read update uses the conversation, user, and message position as its idempotent identity; it never moves the position backward.

Here is a concrete example: two browser tabs can produce two connections for one buyer, while a dropped laptop connection can remain listed briefly. Membership can tell the application that those connections are listed now. It cannot explain whether the buyer is one person at the keyboard, two devices in a bag, or a tab nobody is watching.

Step 1: Query membership through a narrow adapter

The following Go program is runnable against one verified presence route. It deliberately prints the returned JSON without inventing a response schema; discovery is the authority for the current shape. Set INFRAI_API_KEY, then pass a real channel name as the first argument.

package main

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

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func getPresence(ctx context.Context, client *http.Client, baseURL, key, channel string) (json.RawMessage, error) {
    endpoint := strings.TrimRight(baseURL, "/") + "/realtime/presence/get/" + url.PathEscape(channel)
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("presence request failed: status=%d body=%s",
                resp.StatusCode, strings.TrimSpace(string(body)))
        }
        if !json.Valid(body) {
            return nil, fmt.Errorf("presence response was not valid JSON")
        }
        return json.RawMessage(body), nil
    }
    return nil, fmt.Errorf("presence request remained rate limited")
}

func main() {
    baseURL := os.Getenv("INFRAI_BASE_URL")
    key := os.Getenv("INFRAI_API_KEY")
    if len(os.Args) != 2 || baseURL == "" || key == "" {
        fmt.Fprintln(os.Stderr, "set INFRAI_BASE_URL and INFRAI_API_KEY, then run: go run main.go CHANNEL")
        os.Exit(2)
    }
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()
    presence, err := getPresence(ctx, &http.Client{}, baseURL, key, os.Args[1])
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(presence))
}
Enter fullscreen mode Exit fullscreen mode

The adapter's next layer should translate only documented response fields into the application's connection type. It should never manufacture is_read, is_typing, or can_view from this response. Those decisions belong to the independent event and authorization paths described above.

Step 2: Instrument the earlier mismatch

Count connections as connections. Then record typing age, presence reaping age, rejected read updates, and read-position regressions separately. Do not emit an active_user metric from membership; that name embeds the same mistaken inference that caused the page.

A useful alert requires a numerator and a denominator. For example, evaluate stale typing displays divided by displayed typing indicators, grouped by client release and transport. Keep the 5% and 15-second values above as starting hypotheses until production distributions justify them. One stale indicator at low traffic should be a trace to inspect, not a midnight page.

Instrument the transition too. Log a stable conversation identifier, a pseudonymous user identifier, the last event time, the connection state, and the reason the indicator cleared. Never put message text in this operational record. The signal that should fire earlier is a rising typing-expiry age while the UI still renders the indicator, not a count of present members.

Short signals deserve short names.

Step 3: Compare contracts, not presence marketing

Ably, Pusher Channels, and PubNub all publish presence or occupancy concepts, but their client APIs, membership payloads, and lifecycle vocabulary differ. Read each provider's semantics before mapping its events into the three internal signals above. Do not let a provider-specific member object become the authorization record or the read-receipt schema. A provider can make membership convenient without making it authoritative: the marketplace still owns the permission check, typing deadline, multi-tab collapse rule, and monotonic read cursor. That division creates more application code, but it contains the blast radius when transport events arrive late or out of order. It also leaves a useful audit trail: an operator can distinguish "the connection was listed" from "the server accepted read sequence 42" without reverse-engineering a vendor event during an incident.

Option Practical fit Boundary to preserve
Ably Presence Teams already using Ably channels and presence operations Membership remains distinct from application authorization and attention
Pusher Channels Presence Channels Applications comfortable with presence-channel membership events Channel membership must not become proof that a message was read
PubNub Presence Systems that want PubNub's presence event model and occupancy features Timeout and leave behavior still require an explicit UI expiry policy
Infrai realtime Teams that want a plain REST contract behind which the provider can change Its presence result is still connection state; important decisions need server confirmation

Infrai is a reasonable fourth option when the architectural goal is to keep the application contract stable while the service behind that capability moves. Its public discovery surface describes the request and response schema, which helps generate and validate an adapter; the relevant capability is GET /v1/realtime/presence/get/{channel}. That convenience does not widen what presence means.

Infrai uses one API key and one bill across 295 routes in 20 modules. Every documented capability has runnable examples in 10 languages. For a marketplace already consolidating several backend capabilities, the single credential reduces key handling, while the Go example gives the adapter owner a checked starting point when the discovery schema changes. Those are operational conveniences, not evidence that a connected buyer is attentive.

The limitations and trade-offs are concrete. Infrai is not suitable when the team wants a provider-native client SDK and deep coupling to that provider's presence vocabulary; in that case, choose Ably, Pusher Channels, or PubNub according to the existing stack and accept the tighter contract. Conversely, a stable REST boundary matters more when the team expects the service behind a capability to change without changing application code. I would make that contract decision before comparing optional presence fields.

The adapter should return your narrow internal type: connection identifier, subject identifier if available and verified, joined time if supplied, and observed time. Vendor-only fields stay at the edge. This makes a later provider swap a transport change rather than a rewrite of read receipts.

Keep the edge narrow.

Step 4: Close the runbook with server checks

When the alert fires, first compare the last typing event with the UI expiry. Next, inspect connection churn and reaping delay. Then verify that read-position writes were authenticated, authorized, monotonic, and idempotent. Presence is supporting evidence, never the gate for viewing a marketplace conversation or sending as a participant.

Test four cases before release: a clean disconnect, a vanished network, two tabs for one user, and a delayed duplicate read update. The two-tab case matters because one person can have multiple connections; collapsing those connections is an application decision. In a concrete fixture, name the connections buyer-17-web-a and buyer-17-web-b, then assert that closing one tab does not mark the person absent while the other remains connected. The vanished-network case should leave, at worst, a brief ghost rather than an immortal typing indicator, because the typing timer expires independently.

Finally, tune the page for actionability. A threshold that is too loose hides a broken expiry path. A threshold that is too tight pages on expected connection-reaping delay and trains the on-call to ignore it. Alert on sustained user-visible mismatch, and keep expected brief ghosts on a dashboard.

Further reading

Top comments (0)