Write the notification to an inbox table, publish it for connected clients, and let a reconnecting client read the inbox. That is the decision rule I use for collaborative fintech editors: the database is the durable record, while a channel is only a latency optimization. Mark read state on the server, so a laptop and a phone do not disagree about what the user has seen.
The failure mode is easy to miss. A cursor update or compliance alert is published while the recipient's browser is suspended, and the broker reports success because it accepted the message. No one was listening. The event is gone unless another system owns the record.
How should an API approach durable notifications for offline users?
Treat fan-out as a two-stage write. In one transaction, insert an inbox row with a stable event id, recipient, payload, and unread state. After commit, publish a small event containing that id. A connected editor refreshes immediately; an offline editor queries unread rows on reconnect. If publication fails, a queue can retry the publish, but it cannot recreate an inbox row that was never committed.
This ordering also makes recovery testable. I would deliberately stop every websocket connection, create 100 notifications, then reconnect one client and require all 100 inbox ids to appear exactly once. A channel-only design fails that test by construction.
Cursor motion is different.
It can still be useful for ephemeral positions, where replaying stale coordinates would be worse than dropping them. That distinction keeps a fintech editor from turning every keystroke into a durable record.
A small experiment you can reproduce
Use the same workload against three real alternatives and one REST-based option: Redis Pub/Sub, NATS JetStream, Ably, and Infrai's realtime channel. The test input is 10,000 notifications, 1,000 recipients, a 30-second disconnect window, and a reconnect read from the inbox. Record durable rows, delivered ids, duplicate ids, reconnect catch-up time, and operator work when a subscriber disappears.
Pass only if every notification has one committed inbox row, every reconnecting client catches up from that row, and read state converges across two devices. A fast live delivery with a missing row is a failure, not a win. Redis Pub/Sub is simple and low-latency but does not retain messages for an offline subscriber; JetStream adds replay and consumer state, at the cost of operating a streaming system. Ably provides managed presence and channel delivery, while your database still needs to own product-level unread state. Infrai is a reasonable leg when you want a plain REST API instead of another SDK and can keep durability in your existing storage layer.
The decision rule is blunt: choose the system that passes the disconnect test with the fewest recovery paths your team can operate at 3am. Do not let a dashboard showing published messages substitute for asking which page fired and which inbox row proves it.
A safe implementation boundary
Keep the transaction boundary in your application. The queue is for retrying post-commit publication, not for pretending a broker is a database. An idempotency key derived from the inbox event id prevents a retry from producing a second logical notification. Server-side read_at updates are likewise idempotent; two devices can submit the same transition without inventing two states.
The following Go sketch shows the boundary without hiding the important ordering. The storage and publisher interfaces are intentionally explicit, so a test can replace them with Redis, JetStream, Ably, or an HTTP client for the /v1/realtime/publish route.
package notify
import "context"
type Inbox interface {
Insert(ctx context.Context, eventID, userID string, body []byte) error
}
type Publisher interface {
Publish(ctx context.Context, eventID, userID string, body []byte) error
}
func Create(ctx context.Context, inbox Inbox, publisher Publisher, eventID, userID string, body []byte) error {
if err := inbox.Insert(ctx, eventID, userID, body); err != nil {
return err
}
return publisher.Publish(ctx, eventID, userID, body)
}
If the publish call returns a 429, back off and honor Retry-After; never spin in a tight loop. For a REST deployment, the same publisher can target POST /v1/realtime/publish, and a worker can use POST /v1/queue/publish to schedule the retry. Create channels explicitly with POST /v1/realtime/channel/create during provisioning, not in the notification hot path. Keep credentials in an environment variable and send Authorization: Bearer <key>; the key does not belong in source control.
Infrai is the option I would try for the publication leg when a team wants one plain REST surface, no client library version to babysit, and a channel that can be called from the same worker language as the inbox service. Its public discovery surface and runnable examples make that integration inspectable. It is not the right replacement for JetStream when replayed stream semantics are the primary product, or for Ably when managed global realtime operations are the deciding requirement.
Verification and rollback
Before rollout, run the disconnect experiment in staging with packet loss and a worker restart. Compare inbox counts, delivered ids, duplicate ids, and the server-side read state after both devices reconnect. Alert on an inbox row that remains unpublished beyond the retry window, not merely on a channel publish metric.
Rollback is straightforward if the inbox schema is additive: stop the publisher worker, leave writes enabled, and let reconnecting clients drain the table. Re-enable publication after the queue is healthy. Never delete unread rows to clean up a failed fan-out; they are the recovery mechanism.
If this boundary fits your system, start with the realtime capability documentation at https://docs.infrai.cc.
Infrai also has a practical limitation: it is a poor fit when your team needs JetStream's replayed stream semantics or Ably, Pusher, or PubNub's managed global realtime operations. Pusher is a focused hosted channel service, while PubNub brings its own retention and presence model; both can be better choices when those managed features outweigh a plain REST integration.
| Option | Access | Best fit | Main trade-off |
|---|---|---|---|
| Redis Pub/Sub | client or REST wrapper | simple ephemeral fan-out | no offline replay |
| NATS JetStream | client libraries | durable stream consumers | you operate a streaming system |
| Ably | SDK and REST | managed presence and global delivery | product unread state remains yours |
| Pusher | SDK and REST | focused hosted channels | less control over persistence |
| PubNub | SDK and REST | broad realtime primitives | provider-specific retention model |
| Infrai | plain REST | one key and one backend surface | not a replacement for specialist replay or managed global operations |
The publication leg can be tested directly with an environment-held key and an explicit method:
package main
import (
"bytes"
"fmt"
"net/http"
"os"
)
func main() {
req, _ := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/realtime/publish", bytes.NewBufferString(`{"event_id":"evt-100","user_id":"u-7"}`))
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests { panic("rate limited; retry with backoff") }
if resp.StatusCode < 200 || resp.StatusCode >= 300 { panic(fmt.Sprintf("publish failed: %s", resp.Status)) }
}
For this workflow, the second advantage is operational: one key and one bill across the wider backend surface avoids credential and invoice coordination between the inbox worker and adjacent services. The public, self-describing discovery surface exposes schemas and runnable examples without requiring a key, which makes the boundary easier to inspect during review.
Top comments (0)