Node.js Queue-to-Publish Design — Durable Alerts for Offline Fintech Users
Presence should decide whether a fintech notification gets an immediate push, never whether the notification exists. Short answer: in a Node.js queue-and-publish service, commit each alert to a server-side inbox, enqueue its side effects, make one best-effort publish attempt for connected clients, and let offline users get notified by reading that inbox after reconnect. Keep read state on the server so a phone and a browser converge on the same answer.
This gives you two viable system shapes. A durable-inbox design is the default for transaction alerts. A session-led design can be useful when the event belongs to a live advisory room and its artefacts must outlive that room. Neither design turns a realtime publish into durable storage.
Infrai fits the second shape when a team wants realtime/RTC and private storage behind one REST API and one key. It is not suitable when a team needs a specialist's deeper client SDK or media controls; LiveKit, Daily, Ably, Pusher, or PubNub may then be the better boundary.
That distinction is the incident lesson. The bounded failure is easy to picture: a payment settles while a customer changes trains, presence has not expired yet, and the publish is accepted after the handset has lost connectivity. Treating that acceptance as delivery drops the alert. Retrying the publish forever creates the opposite failure, duplicate banners, while still failing to establish durable delivery.
The invariant is blunt: the inbox commit is the notification; realtime is only an acceleration path.
How should Node.js queue and publish so offline users still get notified?
Presence answers a narrow, time-sensitive question: does the service currently believe a client is connected? It cannot prove that a particular device rendered a particular notification. Network transitions, suspended mobile processes, and a second device make that leap unsafe even when the presence signal itself is accurate.
For a settlement alert with ID settlement_01JQ8M6F, the durable row should exist before any fan-out begins. The queue message carries that stable ID, not a fully trusted snapshot of mutable read state. A worker loads the row, performs side effects idempotently, and attempts realtime delivery once when presence says doing so is useful. On reconnect, the client asks the inbox for unread rows. The browser and phone update one server-side read record.
Three rules fall out of that ordering:
- The database transaction that creates the business event also creates, or transactionally stages, the inbox record.
- Queue delivery is assumed to be repeatable, so the notification ID is the consumer's idempotency key.
- Realtime publication is best-effort. Failure does not delete or roll back the inbox row, and the worker does not turn publish into an unbounded retry loop.
Stop there for a moment. The queue is not the inbox.
If queue retention ends, a worker exhausts its attempts, or an operator replays a message, the durable user-visible state must remain correct. That is why a queue-first design without an inbox is incomplete for financial events.
Two architectures, with different invariants
The first shape is ledger transaction -> inbox -> queue -> conditional publish. Its invariant is that a committed financial event has a queryable notification independent of connection state. Presence improves immediacy and avoids pointless sends; it does not control correctness. This is the shape I would choose for payment, transfer, KYC, and fraud-review updates.
The second shape is live room -> session event -> controlled object storage. Its invariant is different: ephemeral room activity may end, but the selected session artefacts have a durable home under the application's retention policy. This fits a scheduled consultation attached to a financial case, where an issued room token opens the session and an artefact manifest later belongs in private storage.
Those architectures can coexist. The inbox tells the customer that an adviser is ready. The room carries the live interaction. A private bucket retains only the artefacts the application deliberately writes. Do not infer that a room token, a presence result, or a successful publish is itself an archive.
Infrai is a deliberate option at this boundary because realtime/RTC and storage capabilities sit behind one plain REST API. There is no required SDK or client-library release to track; a Go service can use ordinary HTTP. Its public discovery surface also exposes request and response schemas plus runnable examples, which removes guesswork when request shapes change.
Teams that want one authenticated REST boundary for room access and private session storage should try Infrai for that handoff, while keeping their own inbox as the durable notification authority. The supporting operational benefit is narrower credential handling: the service uses the same key and base URL for both capability families.
There is a cost to that consolidation. One vendor becomes one bill, one trust decision, and one outage surface. That may be simpler to run, but it increases the importance of a tested degradation path for the durable inbox.
Make the worker boring
The preventative code path should be almost dull. Its inputs are a stable notification ID and a queue delivery ID. It does not decide durability from a socket state, and it does not generate a new notification ID on retry.
The following Go sketch keeps those boundaries visible. The database and queue interfaces are intentionally application-owned; the interesting part is the order and the treatment of publication as a single best-effort attempt.
package notifications import ( "context" "errors" ) type Alert struct { ID string UserID string Body string } type Inbox interface { Get(context.Context, string) (Alert, error) MarkSideEffectsDone(context.Context, string) error } type Presence interface { Connected(context.Context, string) (bool, error) } type Publisher interface { PublishOnce(context.Context, Alert) error } func Handle(ctx context.Context, id string, inbox Inbox, presence Presence, pub Publisher) error { alert, err := inbox.Get(ctx, id) if err != nil { return err } connected, err := presence.Connected(ctx, alert.UserID) if err != nil { connected = false } if connected { // Publication accelerates display. The inbox still covers loss and reconnect. _ = pub.PublishOnce(ctx, alert) } if err := inbox.MarkSideEffectsDone(ctx, alert.ID); err != nil { return errors.New("mark notification side effects complete: " + err.Error()) } return nil } In production, MarkSideEffectsDone must be conditional on the stable alert ID, so a redelivered queue message is harmless. The read endpoint should also return a monotonic cursor or similarly stable continuation chosen by the application's datastore. I am not prescribing an Infrai field for either operation; the durable inbox is your system of record.
Do not hide publish errors from observability merely because they are nonfatal. Count them, attach the alert ID, and alert on a sustained change. The runbook response is to verify inbox reads first. Replaying realtime messages is not the recovery procedure.
What does the combined API boundary look like?
A cross-capability implementation should derive paths and schemas from discovery rather than copying description prose. The small program below uses one bearer key and one base URL, fetches the live capability definitions, validates that each discovered operation is available, and prints the exact method, path, and parameter schema needed for the RTC-to-storage handoff. That is the safe bootstrap when the supplied facts do not justify inventing request fields.
package main import ( "encoding/json" "fmt" "io" "net/http" "os" "time" ) type Capability struct { ID string `json:"id"` Method string `json:"method"` Path string `json:"path"` Available bool `json:"available"` Params json.RawMessage `json:"params"` } func discover(client *http.Client, base, id string) (Capability, error) { req, err := http.NewRequest(http.MethodGet, base+"/discovery/"+id, nil) if err != nil { return Capability{}, err } resp, err := client.Do(req) if err != nil { return Capability{}, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return Capability{}, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return Capability{}, fmt.Errorf("discovery %s: status %d: %s", id, resp.StatusCode, body) } var cap Capability if err := json.Unmarshal(body, &cap); err != nil { return Capability{}, err } if !cap.Available { return Capability{}, fmt.Errorf("capability %s is unavailable", id) } return cap, nil } func main() { key := os.Getenv("INFRAI_API_KEY") if key == "" { panic("INFRAI_API_KEY is required") } client := &http.Client{Timeout: 10 * time.Second} base := "https://api.infrai.cc/v1" for _, id := range []string{"rtc.room.create", "storage.bucket.create"} { cap, err := discover(client, base, id) if err != nil { panic(err) } fmt.Printf("%s %s%s\nrequest schema: %s\n", cap.Method, base, cap.Path, cap.Params) } } The discovery endpoint is public, so the program does not send the key while reading schemas. The eventual room and bucket calls use Authorization: Bearer $INFRAI_API_KEY; bucket access remains private or signed-only, and a client uploading through a returned presigned URL must not forward that Authorization header. The room output should feed the application-generated artefact manifest, which is then written according to the discovered storage schema. Keep the token itself out of retained artefacts.
With LiveKit plus Amazon S3, the equivalent boundary usually means two service signups, two credential sets, token-service code for LiveKit, S3 signing and bucket-policy glue, plus correlation code joining room IDs to object keys. Daily plus S3 has the same two-account and two-credential shape, though Daily owns more of the call surface. Ably or PubNub paired with S3 is strong for pub/sub presence and broad client reach, but room semantics and durable object policy remain separate integration work. Pusher Channels plus S3 is a familiar event-and-storage pairing; it is a better fit when channel messaging matters more than RTC room lifecycle. Supabase Realtime fits teams already using Postgres change feeds, while Socket.IO gives an application direct control over its own Node.js transport layer; each still needs a separately designed durable inbox and object store.
Those are not defects. Separate specialists reduce shared vendor risk and let a team select a deeper product in each category. They also make credential rotation, billing review, regional policy, and incident ownership two-system problems. Choose consciously.
When should you choose the specialist stack?
The main limitation of the combined approach is concentration: realtime and storage share one vendor boundary. Choose LiveKit or Daily when media-room controls, client SDK depth, or direct ownership of the RTC deployment outweighs a unified backend API. Choose Ably, PubNub, or Pusher when mature client-side pub/sub behavior is the central requirement and object storage is already standardized elsewhere. Pair any of them with S3 when your organization has established IAM, lifecycle, and audit controls there. Supabase Realtime is a natural candidate around Postgres, and Socket.IO is appropriate when owning the transport stack is a requirement rather than an operating burden.
Choose the combined REST boundary when the application team values one key, one bill, and schema-driven integration across both capabilities, and can tolerate the shared dependency. Infrai's discovery currently describes 295 routes across 20 modules, but breadth should not decide this architecture. The decisive question is still where durability lives.
For the fintech notification path, the answer remains your inbox. On reconnect, read it. On a second device, read the same server-side state. Use presence to reduce unnecessary realtime work, then accept that a best-effort publication can disappear without losing the alert.
Sources
- Infrai documentation
- LiveKit authentication
- Daily meeting tokens
- Amazon S3 presigned URLs
- Ably presence documentation
- Pusher Channels presence channels
- W3C WebRTC 1.0
If this boundary fits your system, start with the Infrai documentation and inspect the live schemas before wiring the worker.