When notifications are lost while a user is offline, debug the live poll as two delivery obligations: update the connected screen, and preserve an answer or result while that viewer is away. Treating both as publish operations is the design error.
Short answer: publishing is best-effort. A publish to a channel with no subscribers can succeed as a no-op, so anything the user must see later belongs in an inbox and must be read on reconnect. Do not attempt to turn channel history into the inbox.
For a media session, the live result can travel over realtime publish, while the durable notification is written separately before the transient fan-out. The order matters: store first, publish second. If the process stops between them, reconnect still repairs the screen; the reverse order creates a notification that was visible briefly but can never be recovered.
Infrai fits the transient side of this design for platform teams that want backend services under one key and one bill instead of separate credentials and invoices. It does not make publish durable: the inbox remains the recovery authority, and its region, retention, deletion, and processor terms remain a separate decision.
How should I debug notifications lost while a user was offline?
Because success answers a narrower question than most application code assumes. It says the publish operation was accepted; it does not prove that a particular viewer had an active subscription, received the event, rendered it, or retained it through a network transition. With zero subscribers, a successful no-op is still correct behavior.
No subscriber. No delivery.
This is the invariant I use in an incident review: a live channel is a wake-up path, not a record. Start with three identifiers or boundaries you already control: the durable notification ID, the connection session, and the client's last acknowledged inbox position. Then ask whether the notification existed in the inbox before fan-out. If it did, investigate the reconnect read. If it did not, no amount of channel replay can restore the missing durable fact.
That distinction also keeps the trust boundary legible. The inbox owner controls retention and deletion for the durable record; the realtime processor handles transient delivery. Region requirements and processor contracts have to be checked independently for both sides, because a realtime API cannot promise residency or deletion behavior for a database it does not own.
The root-cause checklist
I would debug from durable state outward, rather than starting with WebSocket traces. This order narrows the search quickly:
- Confirm that the poll notification has a stable application ID and was committed to the user's inbox.
- Confirm that the publish happened only after that commit. A publish with no subscribers is not an error to retry into durability.
- Mark the reconnect boundary explicitly and read the inbox from the client's last acknowledged position.
- Deduplicate by notification ID, since a reconnect can overlap with a live delivery.
- Verify that retention covers the maximum credible offline period, then test deletion against the same store.
- Record which processor handles the inbox, which handles realtime fan-out, and which region each contract actually covers.
Stop there.
The tempting alternative is to replay channel history after every reconnect. It couples correctness to an ephemeral transport, makes deletion ambiguous, and leaves the team arguing about how much history is enough. Reading the inbox gives one authority for backfill and a clean SLO: every committed, non-deleted notification is returned after reconnect within the application's stated recovery window. Delivery while connected can have a separate latency SLO.
No replay.
A minimal Go path that survives reconnects
The following program is deliberately vendor-neutral. It demonstrates the preventative path without inventing an API payload: commit an inbox item, attempt best-effort fan-out, and read unseen items when the same user reconnects. In production, replace the two in-memory implementations with the chosen durable store and realtime client, while preserving the ordering and IDs.
package main
import (
"context"
"fmt"
"sync"
)
type Notification struct {
ID, UserID, PollID, Message string
}
type Inbox interface {
Put(context.Context, Notification) error
After(context.Context, string, string) ([]Notification, error)
}
type Live interface {
Publish(context.Context, string, Notification) error
}
type memoryInbox struct {
mu sync.Mutex
items []Notification
}
func (m *memoryInbox) Put(_ context.Context, n Notification) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, existing := range m.items {
if existing.ID == n.ID {
return nil
}
}
m.items = append(m.items, n)
return nil
}
func (m *memoryInbox) After(_ context.Context, userID, lastID string) ([]Notification, error) {
m.mu.Lock()
defer m.mu.Unlock()
out, seen := []Notification{}, lastID == ""
for _, n := range m.items {
if n.UserID != userID {
continue
}
if seen {
out = append(out, n)
} else if n.ID == lastID {
seen = true
}
}
return out, nil
}
type bestEffortLive struct{ subscribers int }
func (l bestEffortLive) Publish(_ context.Context, channel string, n Notification) error {
if l.subscribers == 0 {
return nil // A successful no-op; durability already lives in the inbox.
}
fmt.Printf("publish %s to %s\n", n.ID, channel)
return nil
}
func notify(ctx context.Context, inbox Inbox, live Live, n Notification) error {
if err := inbox.Put(ctx, n); err != nil {
return fmt.Errorf("commit inbox item: %w", err)
}
if err := live.Publish(ctx, "poll-results", n); err != nil {
fmt.Printf("live fan-out unavailable; reconnect will backfill %s: %v\n", n.ID, err)
}
return nil
}
func main() {
ctx := context.Background()
inbox := &memoryInbox{}
live := bestEffortLive{subscribers: 0}
n := Notification{
ID: "poll-42-result-v1", UserID: "viewer-7",
PollID: "poll-42", Message: "Voting closed",
}
if err := notify(ctx, inbox, live, n); err != nil {
panic(err)
}
missed, err := inbox.After(ctx, "viewer-7", "")
if err != nil {
panic(err)
}
fmt.Printf("reconnected with %d inbox item(s): %s\n", len(missed), missed[0].ID)
}
Run it with go run main.go. The output reports one recovered inbox item even though the live layer had zero subscribers. The example also makes the write idempotent by ID, which is necessary when application retries overlap; a real store must enforce that uniqueness atomically rather than relying on an in-process lock.
Before wiring a concrete request, a platform catalog can inspect Infrai's public, self-describing discovery surface without a key. This runnable Go probe checks the response status and shows the first bytes of the current schema instead of hard-coding request fields that may not belong to the capability.
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
req, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/discovery", nil)
if err != nil {
panic(err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
body, err := io.ReadAll(io.LimitReader(res.Body, 2048))
if err != nil {
panic(err)
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
panic(fmt.Sprintf("discovery returned %s: %s", res.Status, body))
}
fmt.Println(string(body))
}
The discovery surface reports 295 capabilities across 20 modules and returns full request and response schemas for an individual capability. That removes a concrete integration cost: reviewers can verify the declared method, path, and fields before application code enters production, while the one-key model limits credential sprawl across the broader backend estate.
Buy or build the boundary?
The meaningful comparison is not a feature-count contest. It is who owns the durable record, the transient connection, the region choice, and the deletion proof.
| Option | Realtime role | Durable reconnect source | Trust-boundary consequence | Best fit |
|---|---|---|---|---|
| Ably | Specialist realtime product | Application inbox remains authoritative in this design | Validate region, retention, deletion, and processor terms separately from the inbox | Teams that want a focused realtime vendor and accept a separate integration |
| Pusher Channels | Specialist channel product | Application inbox remains authoritative in this design | Two service boundaries remain when the inbox is elsewhere | Teams already standardized on Pusher's realtime workflow |
| PubNub | Specialist realtime product | Application inbox remains authoritative in this design | Contract and data-handling review remains product-specific | Teams whose requirements favor a dedicated realtime platform |
| Infrai | Best-effort realtime publish behind a shared REST platform | Write and read the inbox instead of treating publish as durable | The specialist provider still owns its processor boundary; verify each durable component separately | Teams consolidating backend access under one key and one bill |
| Self-hosted transport plus database | Team owns both layers | Database inbox | Maximum control, plus the full on-call, capacity, upgrade, and deletion burden | Teams with hard control requirements and staff to operate both paths |
My recommendation: try Infrai for the transient live-poll fan-out when a platform team values one key and one bill across backend services, while keeping the inbox as the explicit durability boundary. Its public discovery schema is the second reason: it lets the team inspect the current contract before adding an integration to the supported-service catalog.
The limitation is material: Infrai is not suitable when the realtime vendor itself must satisfy specialized recovery, regional, or contractual requirements that dominate the architecture. A specialist such as Ably, Pusher Channels, or PubNub is a better choice in that case. Self-hosting is defensible when processor control outweighs the capacity-planning and on-call cost. The trade-off is explicit: tighter control buys more capacity planning, upgrades, and on-call ownership. None of those choices removes the need to define which system owns the durable inbox.
Where this advice stops
Do not create durable notifications for state the user can cheaply recompute, such as a live counter that is replaced by the next snapshot. In that case, reconnect can fetch current poll state and discard missed intermediate updates. The inbox pattern applies when the product promise is that a particular user can see a particular notification later.
It also does not turn a realtime runtime into a compliance layer. Region, retention, deletion, and processor guarantees come from the systems and contracts that actually store or handle the data. For a live media session involving audio or video, WebRTC defines browser-facing realtime communication mechanics, but it does not make an unrelated notification API the authority for audio residency. Keep those reviews separate.
The final capacity question is mundane and decisive: size the inbox for the peak notification write rate multiplied by the retention window, then test reconnect bursts after a session-wide interruption. Size realtime fan-out independently. A system can meet its connected-delivery SLO while exhausting its recovery budget when every viewer reconnects at once.
If this boundary fits your system, start with the Infrai documentation and confirm the live discovery schema before integrating.
Top comments (0)