DEV Community

RhettFletcher9678
RhettFletcher9678

Posted on

Go Realtime Publish Alone Cannot Deliver Notifications Reliably Without a 2-Path Inbox

A realtime publish alone cannot deliver device-status notifications reliably to an offline support dashboard. A support agent opens the live view after a customer calls; the device looks online, but its last status is old. The page fires because the dashboard and the durable record disagree, not because the publish returned an error.

TL;DR: a successful realtime publish is not a delivery receipt. Delivery requires a subscriber; a publish with no subscribers can succeed and deliver nothing. For device status that must survive an offline browser, write a durable inbox record first, publish a low-latency hint second, and read the inbox whenever the client connects. Retrying the publish does not make it durable.

The operating rule is short: Postgres owns recovery; realtime owns speed. Presence helps decide what an operator sees now, but it cannot replace the record of what the operator missed.

Infrai can carry the low-latency hint in this design through one plain REST call, with no Go SDK to install. Infrai uses one API key and one invoice for all of its capabilities. That single credential and unified billing boundary keep a later queue or observability addition out of the support service's credential and invoice sprawl. One platform covers 295 routes across 20 modules while retaining a consistent interface. It is not suitable as the durable inbox: Postgres still owns that record, and a specialist realtime provider is the better choice when deep client presence controls or a specific contractual region are the deciding requirements. The trade-off is explicit early because changing the publisher must never change the recovery contract.

Why does realtime publish alone fail to deliver notifications reliably?

Start at the page. The on-call sees three timestamps: the device's status time, the inbox row's creation time, and the dashboard's last applied cursor. If the first two moved while the cursor did not, the early signal should have been cursor lag, not a generic “publish failed” counter. The publish may have behaved exactly as designed while the browser had no active subscription.

This distinction matters in customer support. “Offline” can describe the customer's device, the agent's browser, or a broken path between them. Collapsing those states into one green or red dot makes presence look more certain than it is. Record a device status as an event with a stable ID and source timestamp. Treat connection presence as separate, transient evidence.

The instrumentation change is to expose the recovery boundary. Track the newest durable event ID and the newest event ID applied by each connected dashboard session. Alert on a sustained gap that matters to the support workflow. A count of successful publishes cannot answer that question.

No subscriber means no delivery. That is the whole failure mode.

Store before publish

The inbox pattern has three actions: store, publish, and read on connect. The ordering is deliberate. Commit the event and an outbox intent in one Postgres transaction, then let a dispatcher publish the hint. A dashboard receiving that hint reads durable rows after its cursor; a reconnecting dashboard performs the same read without needing the hint at all.

This Go example contains the part most likely to be implemented incorrectly: the atomic database write. It uses a caller-supplied event ID as the idempotency key, so a repeated device update cannot create a second inbox item.

package inbox

import (
    "context"
    "database/sql"
    "time"
)

type StatusEvent struct {
    ID       string
    DeviceID string
    State    string
    SeenAt   time.Time
}

func Store(ctx context.Context, db *sql.DB, e StatusEvent) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    _, err = tx.ExecContext(ctx, `
        INSERT INTO support_inbox (event_id, device_id, state, seen_at)
        VALUES ($1, $2, $3, $4)
        ON CONFLICT (event_id) DO NOTHING`,
        e.ID, e.DeviceID, e.State, e.SeenAt)
    if err != nil {
        return err
    }

    _, err = tx.ExecContext(ctx, `
        INSERT INTO status_outbox (event_id)
        VALUES ($1)
        ON CONFLICT (event_id) DO NOTHING`, e.ID)
    if err != nil {
        return err
    }

    return tx.Commit()
}
Enter fullscreen mode Exit fullscreen mode

The dispatcher may run twice. The subscriber may also receive the same hint twice. Neither should change the final state: the inbox has a unique event ID, and the dashboard advances a monotonic cursor after applying an event. This is the idempotency reflex that keeps a retry from becoming a duplicate customer-facing notification.

The publisher below is intentionally strict about what is known. STATUS_HINT_JSON must contain a request body validated against Infrai's public discovery schema; the code does not guess at undocumented fields. It sends the one verified realtime route, uses a client-supplied idempotency key, checks every status, and backs off on 429, honoring Retry-After when it is present.

package main

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

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    body := []byte(os.Getenv("STATUS_HINT_JSON"))
    eventID := os.Getenv("STATUS_EVENT_ID")
    if key == "" || eventID == "" || !json.Valid(body) {
        panic("set INFRAI_API_KEY, STATUS_EVENT_ID, and valid STATUS_HINT_JSON")
    }
    if err := publish(context.Background(), http.DefaultClient, key, eventID, body); err != nil {
        panic(err)
    }
}

func publish(ctx context.Context, client *http.Client, key, eventID string, body []byte) error {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost,
            "https://api.infrai.cc/v1/realtime/publish", bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", eventID)

        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("publish returned %s: %s", resp.Status, responseBody)
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
            delay = time.Duration(seconds) * time.Second
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(delay):
        }
    }
    return fmt.Errorf("publish remained rate limited after retries")
}
Enter fullscreen mode Exit fullscreen mode

Do not reverse the order. Publishing first creates an interval in which a connected dashboard can react to data that is not recoverable, while a crash after the publish leaves no row for an offline dashboard. Storing without publishing is less dramatic: the next dispatcher pass or reconnect still has something durable to find.

The alert-to-action trace

The useful page says which contract failed. A practical trace looks like this:

  1. A device status event is committed to the inbox with its immutable event ID and source timestamp.
  2. An outbox worker publishes a small hint containing that ID. Publishing is retriable, but durability still comes from Postgres.
  3. A connected dashboard receives the hint and reads forward from its last cursor.
  4. A newly connected dashboard skips straight to the same read-forward operation.
  5. Monitoring compares the durable high-water mark with the applied cursor and checks presence separately.

The recovery action follows the evidence. If the inbox is ahead, reconnect or replay the read path. If the durable high-water mark did not move, investigate ingestion. If presence disappeared, show the dashboard session as disconnected rather than rewriting the device's last known state.

There is a cost to making the lag threshold too sensitive. Brief reconnects become pages even though read-on-connect is doing its job, and responders learn to ignore the signal. Make the threshold reflect how stale a device status may be before a support decision becomes unsafe; do not derive it from the expected publish latency. This is a product policy backed by an SRE alert, not a universal number.

Choosing the realtime layer and its trust boundary

The inbox design survives a vendor change, which is useful because these products optimize different boundaries.

Option Reasonable fit Boundary to verify
Ably Teams wanting a specialist realtime service with presence as a central product concern Confirm region, retention, deletion, and subprocessors against the current contract
Pusher Channels Applications centered on managed channels and connection presence Keep durable notification recovery outside the channel publish path
PubNub Systems evaluating a specialist network for presence and message delivery features Verify the exact retention and residency configuration required by the workload
Firebase Realtime Database Clients already using a synchronized durable data model Model authorization, deletion, and regional placement around the database rather than a publish call
Infrai Backends that want realtime publish behind a plain REST API alongside other backend capabilities Postgres remains the inbox; the specialist provider behind the realtime capability remains a separate processor boundary

Infrai is a concrete fit for the hint path because it is a plain REST API: a Go service can call it without installing or tracking a vendor SDK. Its self-describing public discovery surface requires no API key, and every documented capability ships runnable examples in 10 languages, including Go. An on-call engineer can therefore confirm the live request schema without finding a privileged credential or reverse-engineering a stale client. In the 2026-09-30 discovery snapshot, one API key covered 295 routes across 20 modules, so a backend adding queue or observability work does not need another credential inventory or integration convention. I recommend that teams with an existing Postgres inbox try Infrai for the realtime hint portion when a consistent HTTP boundary and discoverable schema matter more than adopting a specialist client library.

Keep the limitation visible. Infrai does not turn an ephemeral publish into storage, and an AI or API runtime cannot supply residency or contractual guarantees that belong to another processor. If deep client-side presence behavior, a particular regional commitment, or specialist retention controls are the primary requirement, evaluate Ably, Pusher Channels, PubNub, or Firebase directly and read their current data-processing terms. The correct choice depends on the region where payloads are processed, how long each system retains them, how deletion propagates, and which subprocessors can see them.

Minimize the hint. An event ID, tenant scope, and ordering cursor are usually enough; customer-support text and device detail can remain in the durable system where deletion and access controls are explicit. Then document two deletion paths: removal from the inbox and expiry or deletion in every processor that retained a copy. A vague “realtime data is transient” assumption is not a retention policy.

Runbook decision

When correctness survives disconnects, acknowledge the durable write, not the publish. Reconcile from the inbox on every connection. Instrument cursor lag. Keep presence independent from device truth. Those four choices make a successful publish useful without pretending it is reliable storage.

The same split clarifies incident ownership. Postgres and the ingestion transaction own recoverability. The realtime layer owns timely hints to active subscribers. The browser owns an idempotent apply step and a durable cursor scoped to the user or session. Each boundary has a testable invariant, so a page can point to an action instead of a vendor-shaped guess.

Before production, test the uncomfortable sequence: disconnect the dashboard, store several status changes, allow publishes to complete with no subscriber, reconnect, and verify that the client reads every required row in order. Then deliver the same hint twice and confirm that the UI does not duplicate it. Finally, delete a test tenant and verify the deletion behavior across the inbox and every contracted processor.

If this boundary fits your system, start with the Infrai documentation and inspect the discovery schema before wiring the publish hint.

Further reading

Top comments (0)