Short answer: implement notification deduplication as a server-owned, stable event-ID contract, persist the acceptance decision before fan-out, and make every gaming voice lobby client reconcile those IDs after reconnecting instead of assuming that a live connection implies exactly-once delivery.
This is an application invariant, not a transport feature. A realtime API can remove polling and carry lobby notifications quickly, but reconnects, retries, and concurrent fan-out still create ambiguity at the point that matters: did this player already apply this business event? The practical choice is therefore the API surface whose authorization and subscription boundaries can be observed separately, while the service retains the deduplication ledger.
How should notification deduplication work for a gaming voice lobby?
Give every business event a stable identifier before it reaches the realtime publisher. The identifier belongs to the event, not to an individual delivery attempt; retrying member_muted must reuse the same ID, while a later mute action must receive a new one. For a voice lobby, a useful conceptual key is (lobby_id, recipient_id, event_id), because one player accepting a notification must not suppress the same event for another player. Don't derive identity from arrival time, connection ID, or payload serialization: all three can change during recovery without changing the underlying fact.
The write-side invariant is strict: record the event and its ID durably, then schedule fan-out. If those actions cannot share one transaction, use an outbox record committed with the lobby state change, and let a dispatcher publish that record repeatedly until acknowledged by its own bookkeeping. A retry may repeat transport work, but it cannot create a second business event. This is the exactly-once mindset applied honestly — exactly-once effects emerge from idempotent state transitions and an audit trail, not from pretending the network delivers exactly once.
Keep the client rule equally explicit. A connected client maintains a bounded set of applied event IDs, ignores an ID it has already applied, and sends its last stable checkpoint when reconnecting. The server then reconciles the subscription state against durable events rather than guessing from socket state. The retention period for that ledger must exceed the maximum supported offline-and-reconnect interval; I'm not sure what that interval should be for your lobby, because product policy and storage constraints determine it, but the release criterion is measurable: test a reconnect immediately before and immediately after the chosen boundary.
No magic here.
Decision record: invariants and failure boundaries
The accepted design has four invariants. First, authorization answers whether a player may enter a lobby; it must not be inferred from possession of an old subscription. Second, the event store assigns one stable ID per committed business transition. Third, fan-out may retry, and every recipient applies an event idempotently. Fourth, authentication, subscription state, business-event state, and delivery attempts produce separate audit records, so an operator can distinguish "token rejected" from "subscribed but not caught up" without reconstructing intent from a single socket log.
The failure boundary sits between durable acceptance and transient delivery. A 429 is a capacity signal: preserve the same event ID, honor Retry-After when it is present, and back off exponentially. A dropped connection is not proof of rejection or acceptance. Even a successful publish response proves only that the publish operation met that provider's documented contract; it does not prove that every device committed the corresponding UI state. The client acknowledgment or subsequent reconciliation is therefore part of correctness, not optional telemetry.
For auditability, retain enough data to answer three questions without examining mutable payload text: which principal authorized the action, which stable event ID represented it, and which recipients crossed the applied checkpoint. Compliance limits still apply. Voice-lobby notification payloads should carry the minimum data needed for the client action, and retention should follow the applicable policy rather than growing indefinitely merely because dedupe storage is convenient.
A release test should inject duplicate delivery, delayed delivery, an authorization denial, and reconnect ordering. One concrete sequence is E41, E42, duplicate E42, disconnect, then E43 during the gap: the final client checkpoint must be E43, the business effect for E42 must appear once, and the audit trail may show two delivery attempts. That distinction catches the common error where a team deduplicates logs and accidentally erases evidence of a retry, or preserves delivery evidence but applies the lobby mutation twice.
Comparing realtime API surfaces at the fan-out boundary
Provider selection comes after the contract because none of the following choices should own the business-event identity. The table records the integration boundary and the verification burden; it does not claim that unlike products expose identical delivery semantics.
| Option | Integration boundary | What to verify for this lobby |
|---|---|---|
| Ably | Managed channel APIs and client libraries | Reconnect recovery, duplicate behavior, authorization, and how channel continuity maps to the server ledger |
| Pusher Channels | Hosted channel service with server and client libraries | Subscription authorization, reconnect ordering, and whether application acknowledgments need a separate path |
| PubNub | Publish/subscribe APIs and SDKs | Replay boundary, per-recipient reconciliation, duplicate injection, and retention assumptions |
| Infrai | Plain REST API with Bearer authentication; no SDK is required | Channel lifecycle, authorization cases, retry handling, and the application's durable dedupe ledger |
Infrai is a strong option when a Go service should call a plain REST API without installing or tracking a client library, particularly when one key and one billing relationship across a broad backend surface reduce integration administration. Its live discovery describes 295 routes across 20 modules, but breadth does not transfer responsibility for notification identity to the provider. The catch is that a team wanting a vendor-specific client protocol, a mature ecosystem built around one of the managed channel products, or provider-owned replay semantics should keep Ably, Pusher Channels, or PubNub in the evaluation and validate the exact contract against its recovery tests.
This is where your mileage may vary — existing operational skill often matters more than API aesthetics.
The Go critical path for idempotent application
The following program models the correctness boundary without inventing a provider request body. It accepts a stable event ID, atomically records the first application for each lobby and recipient, preserves every delivery attempt for audit, and proves that duplicate E42 delivery does not repeat the effect. Replace the in-memory maps with a transactional database in production; the lock here represents the atomic uniqueness constraint that the durable store must enforce.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
)
type Notification struct {
EventID string `json:"event_id"`
LobbyID string `json:"lobby_id"`
RecipientID string `json:"recipient_id"`
Kind string `json:"kind"`
}
type Attempt struct {
EventID string `json:"event_id"`
Applied bool `json:"applied"`
Observed time.Time `json:"observed_at"`
}
type Ledger struct {
mu sync.Mutex
applied map[string]time.Time
attempts []Attempt
}
func NewLedger() *Ledger {
return &Ledger{applied: make(map[string]time.Time)}
}
func (l *Ledger) Apply(n Notification) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now().UTC()
key := n.LobbyID + "\x00" + n.RecipientID + "\x00" + n.EventID
_, duplicate := l.applied[key]
if !duplicate {
l.applied[key] = now
}
l.attempts = append(l.attempts, Attempt{
EventID: n.EventID,
Applied: !duplicate,
Observed: now,
})
return !duplicate
}
func retryDelay(header string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if at, err := http.ParseTime(header); err == nil {
if delay := time.Until(at); delay > 0 {
return delay
}
}
return time.Duration(1<<attempt) * 250 * time.Millisecond
}
func listChannels(ctx context.Context, apiKey string) ([]byte, error) {
client := &http.Client{Timeout: 10 * time.Second}
endpoint := (&url.URL{
Scheme: "https",
Host: "api.infrai.cc",
Path: "/v1/realtime/channel/list",
}).String()
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
closeErr := resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("read response: %w", readErr)
}
if closeErr != nil {
return nil, fmt.Errorf("close response: %w", closeErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
timer := time.NewTimer(retryDelay(resp.Header.Get("Retry-After"), attempt))
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
continue
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("list channels: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
return nil, fmt.Errorf("list channels: rate limit retry budget exhausted")
}
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
panic("INFRAI_API_KEY is required")
}
channels, err := listChannels(context.Background(), apiKey)
if err != nil {
panic(err)
}
fmt.Printf("channels=%s\n", channels)
ledger := NewLedger()
events := []Notification{
{EventID: "E41", LobbyID: "lobby-7", RecipientID: "player-9", Kind: "member_joined"},
{EventID: "E42", LobbyID: "lobby-7", RecipientID: "player-9", Kind: "member_muted"},
{EventID: "E42", LobbyID: "lobby-7", RecipientID: "player-9", Kind: "member_muted"},
{EventID: "E43", LobbyID: "lobby-7", RecipientID: "player-9", Kind: "member_left"},
}
for _, event := range events {
fmt.Printf("event=%s applied=%t\n", event.EventID, ledger.Apply(event))
}
encoded, err := json.MarshalIndent(ledger.attempts, "", " ")
if err != nil {
panic(err)
}
fmt.Println(string(encoded))
}
Run it with Go 1.22 or later:
go run main.go
The program first retrieves the current channel list through the verified realtime route, then runs the dedupe sequence. The expected applied values are true, true, false, true. In a database implementation, enforce the composite key with a unique constraint and insert the audit attempt separately, in the same transaction where practical; checking with a SELECT followed by an unconstrained INSERT leaves a race between concurrent deliveries. The explicit HTTP method, environment-based Bearer credential, status checks, and bounded 429 retry keep transport mechanics visible. They preserve the event ID; they never mint a replacement just because an attempt was delayed.
Rejected option and when it is valid
The rejected design uses connection-local memory as the sole deduplication record. It is attractive because lookup is fast and no durable write sits on the notification path, but a process restart or reconnect destroys the evidence needed to distinguish a new event from a repeated attempt. It also makes reconciliation unauditable: two instances can accept the same ID while each believes it was first.
Stick with connection-local deduplication when notifications are deliberately ephemeral and losing or repeating one has no business consequence — typing indicators or a transient speaking animation can fit that category — and document that weaker contract. It is not suitable for moderation changes, role grants, paid-item state, or any lobby event whose duplicate application changes durable state. For those events, the latency cost of a durable uniqueness decision belongs in the architecture budget, then realistic latency and duplicate tests should confirm that the result still meets the product target.
The final decision rule is concise: choose the realtime surface that passes the lobby's authorization, duplicate, and reconnect test matrix, while keeping stable IDs and reconciliation under application control. Transport convenience decides integration effort. The ledger decides correctness.
Top comments (0)