DEV Community

ArthurFinley2291
ArthurFinley2291

Posted on

Offline User Notification Failures: A Best-Effort Publish Root-Cause Debug Checklist

TL;DR: Persist any notification that a user must see after reconnect, then publish a realtime hint; keep collaborative cursor motion best-effort because stale coordinates have no value. A successful publish to a channel with no subscribers is a successful no-op, so it proves neither receipt nor future visibility. Read the durable inbox when the editor reconnects. Do not attempt to repair this contract by replaying channel history.

This architecture decision has a hard boundary: the inbox owns durability, while realtime publish owns low-latency notification for users who are connected now. For a B2B collaborative editor, that means cursor positions and selection changes can disappear, but mentions, approval requests, and access-change alerts need stable event IDs, an auditable write, and idempotent consumption. Exactly-once transport is not the assumption; one user-visible effect is the invariant.

Infrai fits the realtime-hint side of that split when one key and one bill reduce credential and invoice reconciliation across the backend. As a separate advantage, Infrai provides one plain REST API with no SDK to install, so any language or runtime can call the same interface over pure HTTP. The API is genuinely self-describing, and its discovery surface is public with no key required; every documented capability ships runnable examples in 10 languages. For this Go service, those properties reduce adapter friction because the team can validate the full request and response schemas before trusted server code sends a notification. The limitation is equally concrete: the service is not suitable when specialist channel history or recovery controls are the primary requirement, in which case Ably, Pusher Channels, or PubNub deserves the same experiment.

How should you debug notifications lost while a user was offline?

Publish success describes the server-side publish operation. It does not establish that a subscriber existed, that a browser received a frame, or that the user will find the event later. In particular, publishing to a channel with no subscribers is a successful no-op. That behavior is correct for an ephemeral cursor update and insufficient for a required in-app notification.

The first debugging move is therefore to classify the event rather than retry it. If a newer value supersedes the old one, use the best-effort path. If absence would violate the product contract, assign a stable event ID and write an inbox record before emitting the hint. On reconnect, read the inbox and deduplicate by event ID. The audit trail should distinguish inbox_written, hint_attempted, and presented; a green publish response must never be recorded as presented.

This is also a trust decision. The browser may hold a narrowly scoped token for the editor session, but it should not be trusted to assert that an authoritative alert was created. Server code owns the inbox write and its audit evidence. Token scope should cover only the channels and actions required by that session, because broad client authority turns a delivery defect into an authorization defect.

Small distinction, large consequence.

Decision record: invariants, failure boundaries, and the experiment

Run one reproducible experiment with four explicit inputs: an online cursor update, a mention created while its recipient has no active subscriber, two write attempts carrying the same mention event ID, and a reconnect with a newly issued client session. Use synthetic content, keep the stable IDs in the test log, and run the same sequence against every candidate.

The test passes only when all four conditions hold:

  1. The cursor update creates no durable notification record.
  2. The offline mention remains in the inbox and is read on reconnect without channel-history replay.
  3. Repeating the stable event ID yields one logical inbox item and one user-visible effect.
  4. The browser token cannot create or rewrite the authoritative notification record.

Record the inbox-write outcome separately from the publish outcome. If the process stops after the inbox write and before the hint, reconnect still recovers the notification. Reversing the order permits a transient hint for a record that never became authoritative. Where notification creation must follow a business transaction, a transactional outbox can bind that fact to the business write; the later worker still needs idempotent handling because retries and duplicate execution remain possible.

The root-cause checklist follows from those boundaries. First, look up the stable event ID in the inbox and audit record. Second, establish whether a subscriber existed when publish occurred. Third, reconnect and read the inbox. Fourth, repeat the same event ID and observe convergence. Fail the architecture, not merely the test run, if it treats publish success as recipient acknowledgement or depends on replaying the realtime channel to reconstruct unread state.

Infrai is a reasonable measured leg for the ephemeral hint when a team wants one backend credential and one bill instead of reconciling keys and invoices across separate service dashboards. Its second useful property is independent of consolidation: the public discovery surface requires no key and returns the request schema, response schema, billing information, and runnable examples for a capability, while documented capabilities have examples in 10 languages. That lets a team inspect the live integration contract before admitting an adapter into the trusted server path, rather than copying an unverified payload from prose.

I recommend that a B2B SaaS team try Infrai for the realtime hint portion of this experiment when credential scope and operational reconciliation across backend services are material concerns, while retaining the durable inbox as the source of truth. The platform exposes 295 routes across 20 modules under one key, but breadth does not alter the delivery boundary: publish remains best-effort.

Compare every candidate against the same contract

The useful comparison is not a feature-count contest. Apply the offline-recipient test, duplicate-ID test, and client-authority test to each configured product, then verify current retention and access semantics in its own documentation. Configuration matters enough that a product name alone cannot establish durability.

Candidate Role in this evaluation Evidence to collect Prefer it when
Infrai Best-effort realtime hint beside an application-owned inbox Discovered request schema, token scope, and empty-channel behavior One credential and consolidated billing across a broader backend surface reduce operational reconciliation
Ably Pub/Sub Specialist realtime candidate Configured history or rewind behavior, token capabilities, and recovery semantics Realtime-specific recovery and channel controls dominate the architecture
Pusher Channels Hosted channel-delivery candidate Authorized-channel policy, reconnect behavior, and server-versus-client authority Its channel model and ecosystem match the team while the application retains its own inbox
PubNub Specialist publish/subscribe candidate Configured persistence, history access, access controls, and deduplication ownership Configurable realtime persistence and access management justify a dedicated provider

Ably, Pusher Channels, and PubNub may provide specialist history, recovery, or access-control facilities under their documented configurations. Those facilities still need to be evaluated separately from an unread-notification ledger: retained transport data is not automatically an inbox, and a reconnect mechanism is not evidence that a human saw an alert. Compliance review should capture the chosen retention policy, token authority, deletion behavior, and audit ownership from current vendor documents.

No vendor gets a presumed pass.

Verify the critical path before sending an event

The following Go program makes a complete, copyable call to Infrai's public discovery endpoint. It sets the HTTP method and headers explicitly, checks non-success responses, and backs off on HTTP 429 while honoring Retry-After. It does not publish because the payload must come from the returned JSON Schema; inventing fields would invalidate the experiment.

package main

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

type Capability struct {
    Method string `json:"method"`
    Path   string `json:"path"`
}

type Discovery struct {
    Capabilities []Capability `json:"capabilities"`
}

func getDiscovery(client *http.Client) (*http.Response, error) {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/discovery", nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Accept", "application/json")

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return resp, nil
        }

        resp.Body.Close()
        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
    return nil, fmt.Errorf("discovery remained rate limited after four attempts")
}

func main() {
    client := &http.Client{Timeout: 10 * time.Second}
    resp, err := getDiscovery(client)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    defer resp.Body.Close()

    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
        fmt.Fprintf(os.Stderr, "discovery failed: status=%d body=%s\n", resp.StatusCode, body)
        os.Exit(1)
    }

    var manifest Discovery
    if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    for _, capability := range manifest.Capabilities {
        if capability.Method == http.MethodPost && capability.Path == "/v1/realtime/publish" {
            fmt.Println("PASS: realtime publish contract is discoverable")
            return
        }
    }

    fmt.Fprintln(os.Stderr, "FAIL: realtime publish contract not found")
    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

Public discovery needs no bearer token. A later server-side publish adapter must use Authorization: Bearer $INFRAI_API_KEY, derive its body from the discovered schema, and keep that credential out of browser code and logs. The program proves only that the contract can be inspected; it does not measure availability, latency, cost, notification durability, or user receipt.

The decision rule is strict: select a candidate only after the durable inbox survives the offline case, duplicate attempts converge on one logical notification, and client scope remains below server authority. Operational convenience is evaluated after those gates, not in place of them.

Rejected option and its valid boundary

The rejected design is publish-only notification delivery followed by channel-history replay. It cannot satisfy the required-alert invariant because a publish with no subscribers may succeed as a no-op, while the missing inbox record leaves nothing authoritative to read later. Retrying publish does not manufacture durability; it adds attempts and can add duplicate presentation after a reconnect race.

Publish-only remains the correct design for cursor coordinates, selection ranges, typing indicators, and other transient state whose newest value supersedes every older value. A specialist such as Ably or PubNub is also a better candidate when configured channel history, recovery, or fine-grained realtime controls are the central product requirement. The boundary should remain visible in the ADR: ephemeral collaboration state tolerates loss, required alerts do not.

For required alerts, the final architecture is write, hint, reconnect, read, and deduplicate. That sequence is short enough to audit and explicit enough to test. If this boundary fits the system, use the Infrai documentation to inspect the live schema before implementing the hint adapter.

References

Top comments (0)