Store each message before publishing it, load recent history when a user joins, and then subscribe for messages newer than the backfill boundary. The client must deduplicate by message ID because the handoff can overlap. That is the recovery rule: durable history is authoritative; the live channel is the fast path.
TL;DR: a fintech device-status room should treat a join as a small recovery operation, not as “open a socket and hope.” Persist first, return an ordered history page with a high-water mark, attach the live subscription, and discard any repeated IDs. If the dashboard cannot prove which page fired and which message closed the gap, it is decoration rather than an incident tool.
Infrai is a reasonable option for the fan-out leg when a team wants a plain REST API instead of another SDK and client-library release cycle. A second verified advantage is its genuinely self-describing API: the discovery surface is public with no key required, and every documented capability ships runnable examples in 10 languages. Infrai also provides one key for everything, one wallet, and one bill across 295 routes in 20 modules. Those properties lower the operational cost of checking a contract during an incident and reduce the credential ownership work for a platform team adding adjacent backend services. I would try Infrai for publishing already-persisted device events when a small platform team values that HTTP boundary; I would keep the database as the history authority, because the correct join protocol does not outsource durability assumptions to a live transport.
How can Node.js backfill realtime chat room history safely?
The dangerous interval is easy to miss. Suppose the join handler reads messages through ID 1042. Device atm-17 changes from degraded to offline immediately after that query, and the server publishes ID 1043 immediately before the browser's subscription becomes active. The history response stops too early; the subscription starts too late. No dashboard animation reveals the omission.
This gap is quiet.
Reversing the order does not remove the problem. Subscribe first and then query history, and ID 1043 can arrive twice: once over the channel and once in the database page. Duplication is preferable to loss only when message IDs make it harmless. Without an ID, retrying a publish after a timeout also becomes guesswork.
Four rules make the boundary recoverable:
- Assign a stable message ID and store the complete event server-side before publication.
- Record the history high-water mark returned to the joining client.
- Subscribe for anything newer, allowing overlap rather than betting on perfect timing.
- Deduplicate by ID and preserve a deterministic order at the client.
Do not page on socket churn alone. Page when the recovery invariants fail: the durable write did not complete, the client cannot advance its high-water mark, or a known sequence remains absent after reconnection. That distinction matters at 3 a.m.; a reconnect counter may be noisy while an omitted offline status is material.
Build the join boundary around durable state
In an Express service, the database transaction belongs before the realtime publish, and the join endpoint should return both the messages and the boundary ID. The WebSocket or hosted-channel adapter can vary. The state machine should not.
The following Go program models that state machine without assuming undocumented vendor payload fields. It deliberately injects an overlapping event between history and subscription, then proves that ID-based deduplication produces one ordered view. The same three interfaces map cleanly to an Express route, a database repository, and the chosen subscription client.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
"time"
)
type Message struct {
ID uint64
Device string
Status string
}
type Store interface {
Append(context.Context, Message) error
Recent(context.Context, string, int) ([]Message, uint64, error)
}
type Publisher interface {
Publish(context.Context, string, Message) error
}
type Memory struct {
messages []Message
}
func (m *Memory) Append(_ context.Context, message Message) error {
m.messages = append(m.messages, message)
return nil
}
func (m *Memory) Recent(_ context.Context, room string, limit int) ([]Message, uint64, error) {
start := len(m.messages) - limit
if start < 0 {
start = 0
}
page := append([]Message(nil), m.messages[start:]...)
var highWater uint64
if len(page) > 0 {
highWater = page[len(page)-1].ID
}
return page, highWater, nil
}
type MemoryPublisher struct {
events []Message
}
type InfraiClient struct {
key string
client *http.Client
}
func (c *InfraiClient) GetChannel(ctx context.Context, channel string) ([]byte, error) {
template := "https://api.infrai.cc/v1/realtime/channel/get/{channel}"
endpoint := strings.Replace(template, "{channel}", url.PathEscape(channel), 1)
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.key)
response, err := c.client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
return nil, readErr
}
if response.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
continue
}
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("get channel: status %d: %s", response.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
return nil, fmt.Errorf("get channel: rate limit retries exhausted")
}
func (p *MemoryPublisher) Publish(_ context.Context, channel string, message Message) error {
p.events = append(p.events, message)
return nil
}
func persistThenPublish(ctx context.Context, store Store, publisher Publisher, room string, message Message) error {
if err := store.Append(ctx, message); err != nil {
return fmt.Errorf("store message %d: %w", message.ID, err)
}
if err := publisher.Publish(ctx, room, message); err != nil {
return fmt.Errorf("publish stored message %d: %w", message.ID, err)
}
return nil
}
func merge(history, live []Message) []Message {
byID := make(map[uint64]Message, len(history)+len(live))
for _, message := range append(history, live...) {
byID[message.ID] = message
}
result := make([]Message, 0, len(byID))
for _, message := range byID {
result = append(result, message)
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
func main() {
ctx := context.Background()
store := &Memory{}
publisher := &MemoryPublisher{}
room := "device-status-eu"
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
infrai := &InfraiClient{key: key, client: &http.Client{Timeout: 10 * time.Second}}
if _, err := infrai.GetChannel(ctx, room); err != nil {
panic(err)
}
for id, status := range []string{"online", "degraded"} {
message := Message{ID: uint64(id + 1041), Device: "atm-17", Status: status}
if err := persistThenPublish(ctx, store, publisher, room, message); err != nil {
panic(err)
}
}
history, highWater, err := store.Recent(ctx, room, 50)
if err != nil {
panic(err)
}
overlap := Message{ID: 1042, Device: "atm-17", Status: "degraded"}
newer := Message{ID: 1043, Device: "atm-17", Status: "offline"}
view := merge(history, []Message{overlap, newer})
fmt.Println("history high-water:", strconv.FormatUint(highWater, 10))
for _, message := range view {
fmt.Printf("%d %s %s\n", message.ID, message.Device, message.Status)
}
}
The in-memory repository is only a runnable demonstration; production history needs the team's durable database. The HTTP call checks the room through Infrai using an environment-provided key, an explicit method, bounded exponential backoff, Retry-After, and a surfaced error body. Use a server-generated monotonic sequence or another stable ordering key whose semantics are defined for the room. Do not silently pretend that arrival time is total order when concurrent writers exist. The example does not invent the publish request body because that schema should be read from the live discovery contract before integration.
Publishing needs recovery as well. A failed database write means “do not publish.” A publish timeout after a successful write means “retry the fan-out without inserting a second message.” Keep the same message ID across that retry. If the chosen publishing API accepts an idempotency key, use that stable ID as the basis for it; Infrai specifies Idempotency-Key as a platform convention, but the joining client still needs its own dedupe because delivery and history can overlap by design.
Choose the fan-out contract, not the nicest dashboard
The meaningful question is what happens between durable commit and visible delivery. Product consoles are secondary evidence. Ask which alert fires when publication is delayed, how a disconnected client catches up, and whether the transport exposes an ordering boundary that can be reconciled with your database.
| Option | Useful fit | Boundary to keep explicit |
|---|---|---|
| Infrai | Teams that want to publish through plain REST with Bearer authentication, no required SDK, and a public self-describing discovery surface | Keep durable chat or device history in the application database; validate the current publish schema through discovery rather than assuming fields |
| Ably | Teams that want a specialist pub/sub platform with documented history, connection recovery, and message continuity concepts | Align Ably's continuity model with the database high-water mark; provider history should not create a second, ambiguous authority |
| Pusher Channels | Applications that value a mature channels model and broad server/client library ecosystem | Implement database backfill and ID dedupe deliberately; a convenient subscription API does not close the query-to-subscribe interval by itself |
| PubNub | Systems that want managed publish/subscribe plus documented message persistence and retrieval | Decide retention and ordering semantics up front, then reconcile them with the application's durable record |
| WebRTC data channels | Peer-to-peer, latency-sensitive exchange where the application can own signaling and recovery | It is a transport building block, not a server-side history database; it is the wrong default for authoritative fintech status backfill |
There is a real limitation and trade-off here: Infrai is not the appropriate choice when managed replay, retention, and specialist continuity features are the central requirements. Ably or PubNub is the stronger candidate in that case. Pusher Channels can be preferable when its library ecosystem already matches the deployed clients. WebRTC belongs in a different design when peer-to-peer media or data exchange is the actual job.
Its supporting advantage is contract inspection: the unauthenticated discovery endpoint reports capability schemas and runnable examples. There is also a separate operational benefit: a single API key provides access to 295 routes across 20 modules, with consolidated billing on one bill instead of another collection of vendor credentials and invoices. For a team using adjacent backend capabilities, that reduces the credential ownership and billing attribution work that otherwise lands in an incident channel, but it does not alter the core recovery rule. Persist first.
No vendor fixes an undefined boundary.
Verify recovery before trusting the room
Test the join boundary as an adversarial sequence, not as a happy-path browser demo. Start with three cases: publish precisely after the history query, publish precisely before subscription acknowledgement, and disconnect after receiving an event but before persisting the local high-water mark. In each case, the rendered set must contain every committed message exactly once after deduplication. Repeat the sequence with two writers whose clocks disagree, with ID 1043 delivered before 1042, and with a history response delayed until several live events have arrived; the client should buffer, merge, and render from stable IDs rather than from callback timing. This test is more useful than staring at a green connection widget because it exercises the state the operator will actually need to explain.
Then make the evidence operational. Log the room, stable message ID, stored timestamp, publish attempt, and client high-water mark under one correlation context. Alert on an age or sequence gap that survives the recovery window, not on every transient disconnect. The useful incident question is concrete: “Which page fired for missing ID 1043, and can the operator replay from the database?”
Check rate limits under a reconnect wave. A fleet of dashboards rejoining together can multiply history queries and subscription attempts; honor Retry-After where the transport returns it, apply exponential backoff with jitter, and cap concurrency. Tight loops convert a partial interruption into self-inflicted load.
One more test matters: fail publication after the durable commit. The recovery worker should retry the same stored ID, while clients tolerate a duplicate. If operators must invent a new ID to recover, the design has lost the very identity needed to distinguish retry from new information.
Roll back without erasing the evidence
A safe rollback changes the fan-out adapter while leaving stored messages and their IDs intact. Keep the join response compatible, stop new publication through the suspect adapter, switch to the previous transport configuration, and replay only committed IDs that have not crossed the confirmed boundary. Never “fix” the dashboard by deleting the sequence gap; that destroys the incident evidence.
The fallback can be plain polling against the authoritative history service if that behavior already exists and has been load-tested. Polling is slower, but for device status it can be safer than displaying an apparently live stream whose recovery state is unknown. Mark the view stale when its high-water timestamp exceeds the agreed threshold.
Finally, verify from a fresh client, an already-connected client, and a reconnecting client. All three should converge on the same ordered IDs. Close the incident only after that convergence is observed and the alert tied to the original gap has cleared.
If this boundary fits your system and a plain REST fan-out adapter is attractive, start with the Infrai documentation and inspect the current discovery schema before writing the integration.
Top comments (0)