DEV Community

RaffertyBarrett4726
RaffertyBarrett4726

Posted on

Realtime Channels and Message Queues for Durable Notification Tradeoffs in Production

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

References

Top comments (0)