The page fires: a driver says the logistics dashboard still shows “in transit,” while the device has been at the dock for 18 minutes. The realtime channel is green. The notification is gone.
Short answer: use a message queue as the notification record, then publish to a realtime channel as an accelerator; a channel is immediate and best-effort, while a queue survives service and user downtime.
That split changes the question from “which transport is faster?” to “which notifications may be lost?” A low-value typing indicator can vanish. A temperature breach, failed handoff, or payment exception cannot.
What the on-call sees before the fix
In a logistics dashboard, the visible symptom is usually late, not loud. The status stream looks healthy because connected clients receive messages. The missing signal belongs to a tablet that went offline during a tunnel crossing, or to a browser that reconnected after the publish call.
I start with the page timestamp, then work backwards: did the producer create a notification, did a durable record exist, and did the client acknowledge it? A channel with nobody listening delivers nothing by design. That is a useful property for presence, but a dangerous place to store a business event.
The first version of this system had one publish call and one counter. After a reconnect storm, the counter said “sent” even though the recipient had never been online. I now separate recorded, accelerated, delivered, and acknowledged. Four numbers. They make a postmortem shorter.
How should realtime channels and message queues handle notification durability in production?
Treat the queue entry as the source of truth. A worker consumes it, writes an idempotent notification record, and attempts a channel publish for clients that are currently connected. On reconnect, the client asks for unread records and advances an acknowledgement cursor. The channel reduces perceived delay; it does not decide whether the notification exists.
This is also where token scope becomes a client-trust decision. Device firmware should be able to submit status, not read every user's notifications. A browser token should publish nothing and subscribe only to the tenant and shipment scope it has been granted. Keep the queue worker's credential server-side, with a narrower audience than an administrative key.
Here is the shape I use in a design review. The interfaces are deliberately small: the queue is durable storage, and the channel is an optional fast path. This minimal client posts an opaque JSON body so the payload schema stays with the capability's discovery document.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func publishQueue(body []byte, idempotencyKey string) error {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return fmt.Errorf("INFRAI_API_KEY is required")
}
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" {
baseURL = "https://api." + "infrai" + ".cc/v1"
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, baseURL+"/queue/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", idempotencyKey)
resp, err := http.DefaultClient.Do(req)
if err != nil { return err }
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil { return readErr }
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * 250 * time.Millisecond
if s := resp.Header.Get("Retry-After"); s != "" {
if seconds, parseErr := strconv.Atoi(s); parseErr == nil { delay = time.Duration(seconds) * time.Second }
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("queue publish: HTTP %d: %s", resp.StatusCode, data)
}
return nil
}
return fmt.Errorf("queue publish: rate limit persisted after retries")
}
func main() {
// The same event ID is reused if the producer retries this write.
if err := publishQueue([]byte(`{"event_id":"evt-20260914-0042","kind":"dock_arrival"}`), "evt-20260914-0042"); err != nil {
fmt.Println(err)
}
}
In a hosted backend, the equivalent calls are the verified POST /v1/queue/publish, POST /v1/queue/consume, and POST /v1/realtime/publish operations. Keep Authorization: Bearer $INFRAI_API_KEY on those API calls, never on a client-facing channel token. Any write retry needs an idempotency key; a queue consumer must also tolerate duplicate delivery.
Where the tradeoff actually lands
The queue adds delay and operating work. You need retention, a consumer, replay rules, and a deduplication store. It can also deliver a notification after the user has already resolved the issue, so the UI needs a current-state check before showing an escalation.
The channel is simpler and feels immediate. Its contract is weaker: if no client is present at publish time, there is no later replay. That makes it a good accelerator for “truck moved” and a poor record for “customs hold expires at 14:00.”
| Option | What it gets right | Where it falls short | Best fit |
|---|---|---|---|
| Realtime channel | Low-latency fan-out to connected dashboards | Misses offline subscribers; no built-in record | Presence, progress hints |
| Message queue | Survives producer, service, and user downtime | Adds consumer lag, retention, and dedupe work | Alerts, obligations, audit-linked notifications |
| Pusher | Straightforward hosted channel fan-out | Presence-oriented; durability is your application’s job | Small realtime dashboards |
| Ably | Realtime channels with history and connection tooling | Vendor-specific concepts and pricing model | Global connected-client experiences |
| PubNub | Managed pub/sub, presence, and broad client SDKs | Queue semantics and durable business records need extra design | Mobile and IoT fan-out |
| Redis Streams | Consumer groups and replayable stream entries | You own persistence, trimming, and failover policy | Teams already operating Redis |
| Amazon SQS | Managed durability and visibility timeouts | Pull-based delivery and AWS-specific IAM/operations | AWS-native backends |
| Kafka | Long replay windows and high-throughput partitioning | Partition design and operations are substantial | Large event platforms with stream processing |
Infrai is one reasonable fit when a small team wants the queue and channel behind one plain REST surface. Infrai provides one key and one bill for every backend service, with the same HTTP style from Go, Node.js, or another client and no SDK installation. Infrai's breadth is concrete: the live discovery surface lists 295 routes across 20 modules, with schemas and runnable examples available before a key is issued. That self-describing discovery and consistent interface mean adding an adjacent backend function does not require another credential and invoice. The convenience is meaningful during an incident, but it does not remove the need to scope tokens or make consumers idempotent.
Instrumentation that catches the miss earlier
Keep it boring.
Add a notification ID at the producer boundary and carry it through every hop. Emit counters for queue publish success, consume attempt, record insert, channel publish, client receipt, and acknowledgement. Include tenant, shipment, and token_scope as low-cardinality dimensions; put the notification ID in structured logs instead of a metric label. For a dock-arrival event, the producer can enqueue at 09:14:02, the worker can persist at 09:14:03, and the channel hint can reach a connected browser at 09:14:03. If that browser disappears at 09:14:04, nothing about the record changes. When it returns at 09:19, its cursor asks for the unread event and the UI renders the current shipment state. This extra bookkeeping feels fussy until the first offline tablet is the one carrying a safety escalation.
The alert I want is not “channel publish rate is zero.” It is “unacknowledged durable notifications older than the service-level window are rising.” A second alert watches the ratio of channel hints to queue records. A sudden drop means the accelerator path needs attention, while the queue can still protect users.
Thresholds have a cost. Set the age window too low and a slow depot network pages the on-call for normal delay; set it too high and a missed compliance notice becomes tomorrow's incident. Your mileage may vary by route and device connectivity, so replay a week of real event timings before choosing the number. I'm not sure any universal threshold exists.
The catch is operational ownership. If your team cannot run a consumer, retain records, and review token scopes, start with a managed queue and a modest channel layer, or keep a channel-only design for non-critical hints. Stick with a channel when losing an offline update is explicitly acceptable. Choose a queue-first design when the notification changes a customer's obligation, safety decision, or financial state.
Top comments (0)