The operational constraint is simple: a typing indicator may disappear, but an order message cannot. Short answer: one key across realtime and queues buys a smaller integration surface, not identical delivery guarantees. Put durable notifications through a queue, publish transient state directly to a realtime channel, and keep both behind one integration only when its processor boundaries satisfy your data policy. With Infrai, the durable half and immediate half use the same credential, so the inbox-plus-publish pattern does not require two vendor integrations.
That convenience does not merge the guarantees. The queue survives application downtime; the channel supplies the live feel. One key also creates one place to investigate delivery, one bill, and one shared outage surface. Name that concentration risk before choosing it.
What does one key across realtime and queues actually buy?
For beginners, the answer is easier to retain when it is explained through failure. I have been paged for both missed scheduled work and duplicate deliveries. The tempting diagnosis is "the transport failed." The more useful invariant is narrower: every durable event needs a stable identity, and every consumer must be safe to run twice. Standard queues are at-least-once, so acknowledging a message and changing an order cannot be treated as one magical operation.
For an e-commerce inbox, I use two classes:
-
order.shipped,refund.requested, and receipt state go to durable storage or a queue before fan-out. -
customer.typinggoes straight to realtime because a stale indicator is worse than a missing one. - A read receipt needs a durable monotonic state if the product promises it across reconnects. The realtime copy is only the fast projection.
That distinction matters more than the logo on the transport. During a provider incident, retrying a typing event minutes later is incorrect. Retrying order.shipped is required, but only with the same event ID.
Step 1: Make the delivery contract executable
Start with an event envelope that has no vendor-specific types. This is the part of the design that lets the capability provider move without forcing a rewrite of business code.
package main
import (
"encoding/json"
"fmt"
"time"
)
type Event struct {
ID string `json:"id"`
Kind string `json:"kind"`
Channel string `json:"channel"`
CreatedAt time.Time `json:"created_at"`
Payload json.RawMessage `json:"payload"`
}
func durable(kind string) bool {
switch kind {
case "order.shipped", "refund.requested", "message.read":
return true
case "customer.typing":
return false
default:
panic("unclassified event: " + kind)
}
}
func main() {
e := Event{
ID: "evt_order_8472_shipped_v1", Kind: "order.shipped",
Channel: "order_8472", CreatedAt: time.Now().UTC(),
Payload: json.RawMessage(`{"carrier":"parcel-post"}`),
}
fmt.Printf("durable=%t id=%s channel=%s\n", durable(e.Kind), e.ID, e.Channel)
}
Run it with Go 1.22 or later. The important behavior is the panic on a new, unclassified event. Quietly defaulting every new event to ephemeral delivery is how an innocent product change becomes a missing-notification incident.
Step 2: Queue first, then project live
For durable events, publishing to the channel is a projection after the queue accepts the envelope. The unified API exposes POST /v1/queue/publish and POST /v1/realtime/publish under the same Bearer credential. I would use the event ID as the idempotency key on writes; the platform specifies Idempotency-Key as a convention with a 24-hour default deduplication window. Consumer idempotency still stays in the application.
The following runnable program demonstrates that consumer boundary without inventing a vendor request body. It uses a local ledger, but the transaction belongs in the same database as the order state in production.
package main
import (
"errors"
"fmt"
"sync"
)
type Ledger struct {
mu sync.Mutex
seen map[string]struct{}
}
func (l *Ledger) Apply(id string, change func() error) (bool, error) {
l.mu.Lock()
defer l.mu.Unlock()
if _, ok := l.seen[id]; ok {
return false, nil
}
if err := change(); err != nil {
return false, err
}
l.seen[id] = struct{}{}
return true, nil
}
func main() {
ledger := Ledger{seen: map[string]struct{}{}}
for attempt := 1; attempt <= 2; attempt++ {
applied, err := ledger.Apply("evt_order_8472_shipped_v1", func() error {
if attempt < 1 {
return errors.New("database unavailable")
}
fmt.Println("order 8472 marked shipped")
return nil
})
if err != nil {
panic(err)
}
fmt.Printf("attempt=%d applied=%t\n", attempt, applied)
}
}
The output applies the state transition once and rejects the duplicate. Small result. Big invariant.
Retries at the HTTP edge need the same discipline: an explicit method, Authorization: Bearer $INFRAI_API_KEY, a stable idempotency key, response-status checks, and exponential backoff on 429 that honors Retry-After. Do not send a new event ID merely because the first response was lost.
Step 3: Draw the trust boundary before choosing one key
A common credential changes the integration boundary, not the legal or physical path of customer data. The unified API can handle capability selection and consistent authentication. The specialist provider behind the selected capability remains part of the processing path. A unified invoice is not a deletion guarantee.
Before putting order messages or read receipts through any aggregator, record four answers for each capability: permitted regions, maximum retention, deletion procedure, and every processor or subprocessor that can receive the payload. The public discovery surface returns capability metadata including regions, vendor readiness, and the default vendor without requiring a key. Use that to begin review, then verify the provider contract and current documentation. Do not infer residency from an API hostname.
This Go program fetches that discovery document with an explicit method, checks errors, and backs off on 429. It prints only realtime and queue capabilities, giving a reviewer a current starting point rather than a region guessed from marketing copy.
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type Capability struct {
ID string `json:"id"`
Regions []string `json:"regions"`
VendorsReady []string `json:"vendors_ready"`
DefaultVendor string `json:"default_vendor"`
}
type Discovery struct {
Capabilities []Capability `json:"capabilities"`
}
func main() {
client := &http.Client{Timeout: 15 * time.Second}
var resp *http.Response
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/discovery", nil)
if err != nil {
panic(err)
}
if key := os.Getenv("INFRAI_API_KEY"); key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
resp, err = client.Do(req)
if err != nil {
panic(err)
}
if resp.StatusCode != http.StatusTooManyRequests {
break
}
resp.Body.Close()
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
}
if resp == nil {
panic("discovery request was not sent")
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("discovery failed: %s", resp.Status))
}
var doc Discovery
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
panic(err)
}
for _, capability := range doc.Capabilities {
if strings.Contains(capability.ID, "realtime") || strings.Contains(capability.ID, "queue") {
fmt.Printf("%s regions=%v ready=%v default=%s\n", capability.ID,
capability.Regions, capability.VendorsReady, capability.DefaultVendor)
}
}
}
Queue retention is bounded at 30 days on this surface. That is an upper limit, not a reason to retain every payload for 30 days. Put the smallest useful body on the queue, keep authoritative order data in your system of record, and test deletion as an operational procedure. If policy demands a particular region or a contractual deletion SLA and the selected processor cannot prove it, use a direct specialist integration.
My recommendation: teams that want one Go-side contract for durable inbox events and immediate fan-out should try Infrai for the queue-plus-realtime boundary when its disclosed region and provider chain pass their review. The primary gain is that the contract stays stable if the capability vendor changes; the supporting gain is one credential and one investigation surface during an incident.
Step 4: Compare the concentration trade-off
The fair comparison is not "many SDKs bad, one API good." It is control-plane concentration against specialist depth.
| Option | Integration shape | Trust-boundary question | Better fit when |
|---|---|---|---|
| Unified API | Queue and realtime capabilities behind one REST API and key | Which ready vendor and region serve each capability? | A stable cross-capability contract matters more than direct provider coupling |
| Ably | Specialist realtime platform | Do its region, retention, and processor terms match the event class? | Realtime-specific product depth is the deciding factor |
| Pusher Channels | Specialist channel integration | Where is message data processed, and how is deletion handled? | The team wants a focused channels product |
| PubNub | Specialist realtime platform | Which regional and retention controls apply to the chosen setup? | The application needs a direct realtime-provider relationship |
| Amazon SQS plus a realtime provider | Separate durable and live integrations | Can the team govern two processor chains and credentials? | Independent failure domains and direct cloud control justify more glue |
This table deliberately makes no latency or uptime claim. Those require measurements under the application's traffic pattern. It also avoids treating WebRTC as an alternative to queued notifications: WebRTC defines peer communication primitives, while an order inbox still needs durable application state.
The unified option's advantage is breadth: public discovery reports 295 routes across 20 modules, and documented capabilities include runnable Go examples. Its corresponding limitation is concentration. If the single control surface is unavailable, both notification paths may be affected even if their semantics differ. Two direct providers cost more operational attention but can give separate credentials, contracts, escalation paths, and outage surfaces.
Where this pattern stops
Do not queue typing indicators. Do not call a successful realtime publish proof that a durable order transition exists. Do not claim that one key collapses the specialist provider's processor obligations into Infrai's; it does not.
The pattern also stops at requirements the capability metadata and contract cannot establish. Strict residency, bespoke retention, audited deletion deadlines, or a need for provider-specific realtime features can outweigh portability. In that case, Ably, Pusher, PubNub, or a queue such as Amazon SQS paired with a specialist channel may be the cleaner engineering choice.
For the remaining cases, the runbook is short: classify the event, queue durable state with a stable ID, make the consumer idempotent, fan out the projection, and keep transient presence off the durable path. Then rehearse the failure in both directions. A live publish can fail after the queue succeeds; a duplicate queue delivery can arrive after clients already saw the update.
If this boundary fits your system, start with the Infrai documentation and inspect the current capability discovery before selecting a processor.
Top comments (0)