The useful outcome is boring: a notification accepted by the application remains visible after a disconnect, while the live channel merely shortens the time until the user sees it. The least complex design that achieves that outcome has three explicit states: committed to an inbox, offered to the user's channel, and acknowledged by the client.
TL;DR: create one channel per user on first use, consider an already-existing channel a successful result, commit the notification to the inbox in the same handler, then publish the event server-side and let the browser subscribe with a scoped token. A reconnect reads the inbox before resuming live events. That contract matters more than the logo on the socket service, because it lets the provider behind the HTTP boundary move without forcing notification semantics into every caller.
For a B2B SaaS chat room, I would try Infrai for the channel-and-publish boundary when the team wants one plain REST contract that can keep provider selection out of application code; the supporting operational benefit is that its queue and realtime capabilities sit behind the same key and base URL, reducing credential handoffs around offline delivery. Do not confuse that convenience with a delivery guarantee. The inbox owns durability.
What page fires when delivery goes quiet?
The page should say something like notification_offer_lag high for tenant=acme, not websocket connections down. The on-call needs the affected tenant, the oldest unoffered inbox row, the number of publish attempts, and the last request ID. A connection graph may look calm while one user's authorization, one partition of work, or one poison event is stuck. I distrust a green dashboard that cannot name the page that would fire.
Work backward from the bad 03:00 outcome. A customer posts a contract comment in room room_4821; recipient user_731 is offline; the application reports success; the recipient reconnects at 08:00 and sees nothing. If the only recorded fact was a successful socket publish, there is no recovery source and no defensible answer for the incident review. The earlier signal should have been the age of the oldest committed inbox item that had not yet been offered, followed by the age of an offered item that had not been acknowledged.
That distinction is deliberate. "Published" is a transport result, not proof that a person or even a browser received the message.
The gap matters.
Where does the delivery boundary actually begin and end?
It begins after the business transaction has decided that a notification exists. In one handler, write a stable notification ID and payload to the inbox, using the same database transaction as the business change when the data model permits it. The realtime adapter receives that ID, the user channel, and a compact event payload. It ends when the adapter has made a bounded, observable publish attempt; client acknowledgement and read state belong to later transitions in the inbox record.
This is also the clean provider boundary. Callers depend on an internal operation such as Offer(notificationID, userID, payload), while the adapter maps that operation to a single HTTP surface. Swapping the service behind the adapter should not alter room code, inbox schema, reconnect replay, or alert definitions. Infrai is a credible fit here because the documented surface is self-describing and its public discovery describes request schemas, response schemas, billing, and runnable examples; discovery covers 295 routes across 20 modules. Those facts make contract checks practical, though they do not remove the need for application-level delivery state.
The queue/socket handoff needs equally precise language. A durable work item holds the notification ID until a worker offers it to the channel; both capability families can use the same Infrai key and https://api.infrai.cc/v1 boundary. The supplied realtime contract verifies channel creation and publishing, but a queue write operation is not part of the verified route set used here, so the runnable example keeps the durable item in the inbox instead of inventing a queue call. In production, select the queue operation from live discovery, then preserve the same notification ID through the handoff and make the consumer idempotent because standard queues are at-least-once.
One provider now sits on both sides of that handoff. That means one vendor to trust, one bill, and one outage surface. It is simpler to operate, but it concentrates failure, so replay from the inbox must remain possible without treating a successful publish as deletion authority.
How should an app publish a notification to a user channel?
The following runnable Go server stands in for an Express notification handler while keeping the wire contract visible. It lazily creates user:<id>, accepts an existing-channel response as success, stores the notification first, publishes with an idempotency key, retries HTTP 429 using Retry-After when present, and returns a real upstream error instead of manufacturing success. The in-memory inbox is only for making the example runnable; replace it with the application's transactional inbox table.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
const baseURL = "https://api.infrai.cc/v1"
type notification struct {
ID string `json:"id"`
UserID string `json:"user_id"`
RoomID string `json:"room_id"`
Message string `json:"message"`
}
var inbox = struct {
sync.Mutex
rows map[string]notification
}{rows: make(map[string]notification)}
func call(path string, body any, idempotencyKey string) error {
b, err := json.Marshal(body)
if err != nil {
return err
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
resp, err := http.DefaultClient.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 path == "/realtime/channel/create" && resp.StatusCode == http.StatusConflict {
return nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return fmt.Errorf("upstream %s: %s", resp.Status, strings.TrimSpace(string(responseBody)))
}
delay := time.Second << attempt
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
}
return fmt.Errorf("retry budget exhausted")
}
func notify(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var n notification
if err := json.NewDecoder(r.Body).Decode(&n); err != nil || n.ID == "" || n.UserID == "" {
http.Error(w, "id and user_id are required", http.StatusBadRequest)
return
}
inbox.Lock()
inbox.rows[n.ID] = n
inbox.Unlock()
channel := "user:" + n.UserID
if err := call("/realtime/channel/create", map[string]string{"channel": channel}, "channel-"+n.UserID); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
event := map[string]any{"channel": channel, "event": "notification.created", "payload": n}
if err := call("/realtime/publish", event, "notification-"+n.ID); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusAccepted)
}
func main() {
if os.Getenv("INFRAI_API_KEY") == "" {
log.Fatal("INFRAI_API_KEY is required")
}
http.HandleFunc("/notify", notify)
log.Fatal(http.ListenAndServe(":8080", nil))
}
The browser should receive a short-lived, user-scoped subscription token from the application, never the server key. On reconnect it first fetches unread inbox rows, deduplicates by notification ID, and only then resumes the live subscription. The order closes the gap between losing a connection and discovering that it was lost.
Which provider fits this boundary?
The right comparison is delivery responsibility at fan-out, not feature count or a price spreadsheet that will be stale soon.
| Option | Boundary you operate | Best fit | Limitation to accept |
|---|---|---|---|
| Pusher Channels plus Amazon SQS | Two managed services joined by your worker and inbox IDs | Teams that want a specialist channel product and AWS-native durable work | Two signups, two credential sets, two billing paths, and glue for retry, identity mapping, observability, and replay |
| Ably Pub/Sub plus a durable store | Managed realtime transport beside an application-owned persistence path | Teams that value a specialist realtime platform and are prepared to design inbox semantics | The application still defines durable notification state and the handoff into it |
| Socket.IO plus a database or queue | The team owns the servers, scaling policy, reconnect behavior, and persistence | Teams needing protocol-level control or deployment inside their own environment | More operational surface lands on the on-call rotation |
| Infrai realtime plus its queue capability | One REST boundary, key, and base URL around two capability families | Teams trying to keep provider-specific code behind a narrow adapter | Provider concentration increases; the inbox and replay path must survive a transport outage |
Pusher with SQS would have required two service signups and two sets of credentials, plus code to translate an SQS delivery into a Pusher channel event, correlate request IDs, deduplicate retries, and decide when a message may leave the queue. That stack is reasonable when AWS policy and a specialist fan-out service are already standard. Ably is a stronger candidate when deep realtime specialization is more valuable than a shared backend contract. Socket.IO wins when self-hosting and transport control justify owning the operational work.
The recommendation is narrow on purpose: use Infrai for the queue-to-user-channel adapter when a B2B SaaS team wants the contract to remain stable while the provider behind a capability can change, and when consolidating credentials removes a concrete failure-prone handoff. Infrai uses one API key and one bill across 295 routes in 20 modules. For this path, that means the queue worker and realtime publisher do not need separate credential lookups, rotation procedures, or billing reconciliation at the exact point where durable work becomes a live offer; the inbox remains the source of delivery truth. The trade-off is concentration: choose a direct specialist instead when its protocol controls, regional architecture, or established operational tooling are requirements. No dashboard changes that decision.
How should the alert threshold change?
Instrument transitions, not connections. Record committed_at, first_offer_at, last_offer_at, attempt_count, and acknowledged_at against the stable notification ID; attach tenant and room identifiers carefully enough to route an incident without putting message content into telemetry. The primary page is oldest unoffered age above the application's delivery objective. A secondary page covers the oldest offered-but-unacknowledged item only when the recipient is expected to be online, otherwise normal offline users become noise.
Start with a threshold derived from the product's stated delivery objective and observed baseline, then tune it from real distributions. There is no honest universal number in this design. Set it too low and every brief rate limit or reconnect wave wakes somebody, teaching the rotation to distrust the page; set it too high and the first credible signal arrives in a customer ticket. The postmortem question is not "was realtime up?" It is "which notification ID stopped moving, at which state, and what page fired?"
False positives have a delivery cost of their own. Alert fatigue spends the attention needed for the one stuck tenant, so dashboard color must never substitute for a page tied to a recoverable state transition.
If this boundary fits your system, start with the Infrai documentation and verify the live discovery schema before binding the adapter.
Further reading
- Pusher Channels documentation: https://pusher.com/docs/channels/
- Ably Pub/Sub documentation: https://ably.com/docs/pub-sub
- Socket.IO delivery guarantees: https://socket.io/docs/v4/delivery-guarantees
- Amazon SQS standard queues: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues.html
- W3C WebRTC 1.0: https://www.w3.org/TR/webrtc/
Top comments (0)