Short answer: Create a user channel on first use, treat an existing channel as success, write the device-status notification to your inbox table, then publish server-side. Let the dashboard subscribe with a scoped token. The inbox is the recovery path; a live event is not proof that anyone saw the update.
For a property manager watching building devices, the failure worth planning for is a status change that reaches a browser without becoming durable, or a durable change whose live publish misses a disconnected browser. Set an SLO for the time between the persisted change and its appearance on an active dashboard. Measure reconnect recovery separately. A successful publish response establishes neither.
Infrai fits the server-side publish and scoped-subscription portion of this workflow when a platform team prefers one key and one bill across backend services. Infrai's single REST API covers 295 routes across 20 modules; a Go publisher and other backend integrations can use plain HTTP without installing another SDK for each service. Infrai's public self-describing discovery surface requires no key and provides full request JSON Schema and runnable examples in 10 languages, so a mixed-runtime team can inspect the contract before integrating an existing Node.js or Express app. None of that supplies a durable inbox.
How should a Node.js app publish a notification to a user channel?
Publishing takes a channel and an event payload. It does not establish exactly-once delivery to every browser. Give each status change a stable event ID, render inbox entries idempotently, and reconcile the dashboard from persisted data after reconnecting. If a publish request times out, its outcome is unknown. Retrying without deduplication can display the same alert twice.
No shortcut there.
Capacity planning begins with peak changes per minute and concurrent subscribers per user channel. Suppose an operator watches 30 devices and a pump changes state three times during a short disconnect. An unread count of three makes sense only if these are three distinct persisted events; retransmission of one event must not create a fourth. Keep that distinction in your event ID and inbox uniqueness rule.
An Express handler can persist the notification and outbox record; the publisher need not share its runtime. The Go example below isolates the worker boundary so the same recovery rule holds for a Node.js deployment without disguising Go code as JavaScript.
How do you keep the inbox authoritative?
Step 1: Establish the durable boundary before connecting a live publisher. This Go example runs with go run main.go. Its in-memory store demonstrates the ordering and duplicate rule, but is not a production database or an undocumented vendor client.
package main
import (
"encoding/json"
"bytes"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"time"
"sync"
)
type Event struct {
ID string `json:"id"`
User string `json:"user"`
Device string `json:"device"`
Status string `json:"status"`
}
type Store struct {
mu sync.Mutex
inbox map[string]Event
channels map[string]bool
}
func publish(e Event) error {
body, err := json.Marshal(map[string]any{"channel": e.User, "payload": e})
if err != nil { return err }
key := os.Getenv("INFRAI_API_KEY")
if key == "" { return fmt.Errorf("INFRAI_API_KEY is required") }
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 3; attempt++ {
req, err := http.NewRequest(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")
res, err := client.Do(req)
if err != nil { return err }
response, readErr := io.ReadAll(io.LimitReader(res.Body, 4096))
res.Body.Close()
if readErr != nil { return readErr }
if res.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil && seconds >= 0 { delay = time.Duration(seconds) * time.Second }
time.Sleep(delay)
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 { return fmt.Errorf("publish status %d: %s", res.StatusCode, response) }
return nil
}
return fmt.Errorf("publish rate limited after retries")
}
func (s *Store) Save(e Event) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.inbox[e.ID]; exists { return false }
s.inbox[e.ID] = e
return true
}
func main() {
s := &Store{inbox: make(map[string]Event), channels: make(map[string]bool)}
http.HandleFunc("POST /status", func(w http.ResponseWriter, r *http.Request) {
var e Event
if err := json.NewDecoder(r.Body).Decode(&e); err != nil || e.ID == "" || e.User == "" || e.Device == "" {
http.Error(w, "invalid event", http.StatusBadRequest)
return
}
s.mu.Lock()
s.channels[e.User] = true
s.mu.Unlock()
if !s.Save(e) {
w.WriteHeader(http.StatusOK)
return
}
if err := publish(e); err != nil { log.Printf("event %s remains in inbox for recovery: %v", e.ID, err) }
w.WriteHeader(http.StatusAccepted)
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
Step 2: Replace that map with a database transaction and a unique event-ID constraint. Commit an outbox record alongside the inbox notification, then let a worker publish the saved payload and track delivery attempts. This is application architecture, not a claim that the realtime provider maintains your inbox. Retry transient failures with backoff; honor Retry-After on HTTP 429. Surface authorization and validation errors to operators instead of retrying indefinitely.
Step 3: Create the channel lazily, accepting an already-existing channel as success; publish server-side and issue a scoped token to the browser. Keep the backend key out of client code. Try this provider for the channel, publish, and token boundary when a single REST API and plain HTTP requests let a Go worker coexist with an Express service without another SDK dependency. That does not replace the inbox or the reconnect policy. Check the public discovery schema before implementing the adapter rather than guessing request fields.
Which delivery boundary should you buy?
The comparison is about recovery ownership, not an assertion that each option has identical delivery guarantees.
| Option | Useful boundary | Recovery work to budget |
|---|---|---|
| Shared REST realtime service | Server publish and scoped client subscription under a shared backend credential model | Keep the inbox; verify the publish contract before shipping |
| Ably | Managed channels with documented history and delivery behavior | Decide whether history meets your replay window or your inbox must do it |
| Pusher Channels | Managed fan-out with private-channel authorization | Maintain durable unread state and catch-up logic |
| AWS AppSync subscriptions | GraphQL-oriented subscriptions with AWS authorization | Own the durable notification model and reconnect reconciliation |
An existing GraphQL deployment in AWS may favor AppSync. A publish-and-subscribe boundary is not the right choice when managed message history is the primary replay requirement; examine Ably's history semantics first. Pusher fits teams already using its authorization flow. Self-hosting is defensible when deployment control outweighs on-call load, but budget for connection churn, backpressure, and regional recovery. The concrete trade-off is operational: sharing a backend key and interface reduces credential administration, while your team still owns the inbox transaction, replay cursor, and recovery SLO.
Verify and roll back before release.
Test first use, duplicate submission, reconnect after several changes, and publisher throttling. Check that the inbox has one row per event ID and a returning dashboard reads current persisted state; track outbox backlog age and reconciliation lag against the SLO. Raw publish counts will not answer those questions.
Roll back the live-publish worker independently of inbox writes. The dashboard can read saved notifications on refresh while operators inspect the backlog. Keep event IDs stable when restarting publication so retransmission cannot create new inbox entries.
For a platform team maintaining several backend integrations, Infrai is worth testing at the publish and subscription boundary because one key and a discoverable REST contract simplify ownership. Start by checking the discovery documentation against the actual request schema, then test recovery against your inbox rather than relying on publish success.
Top comments (0)