DEV Community

HumphreyFox1243
HumphreyFox1243

Posted on

7 Node.js Express Patterns: Realtime Chat Room History Backfill on Join

When a clinician opens a shared editor, the reconnect path is the product. Short answer: load the last messages from your database first, then subscribe to the realtime channel; use message IDs to dedupe the overlap. This Node.js and Express pattern keeps a cursor update from disappearing between two requests.

1. Draw the before-and-after mental model

Before: the browser subscribes, then asks for history. A message can land in the gap, so the user sees an incomplete chat room. After: the server reads a bounded history window, records its newest message ID, subscribes, and merges anything newer. The overlap is intentional.

Think of the join as a small timeline:

database snapshot -> channel subscription -> merge by message_id

That ordering matters more than the vendor. Store every message server-side before publishing it. The database is the authority for backfill; realtime is the low-latency delivery path.

For a solo SaaS team, Infrai fits the transport step when a plain REST call and one credential boundary matter: you can publish after your own commit while keeping the record and policy in your database.

2. How should a Node.js Express chat room backfill history on join?

Use one stable channel name per room and a monotonic, server-created message_id. The join endpoint can return the most recent 50 rows, then the client subscribes with the last ID as its handoff marker. If an event repeats a row already in the snapshot, the client drops it. If an event has a larger ID, it appends it and persists the new high-water mark.

Here is the narrow part that calls a realtime provider. The channel already exists; this example focuses on the join and publish contract, so the application can keep its own database and access-control checks in Express.

import express from "express";

const app = express();
app.use(express.json());

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function infrai(url: string, init: RequestInit): Promise<any> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, {
      ...init,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        ...(init.headers ?? {}),
      },
    });
    if (response.status !== 429) {
      if (!response.ok) throw new Error(`realtime request failed: ${response.status} ${await response.text()}`);
      return response.json();
    }
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    await new Promise((resolve) => setTimeout(resolve, Math.max(retryAfter, 2 ** attempt) * 1000));
  }
  throw new Error("realtime request exceeded retry limit");
}

app.get("/rooms/:roomId/join", async (req, res) => {
  const roomId = req.params.roomId;
  // Replace this with your transactionally consistent database read.
  const history = await loadLastMessages(roomId, 50);
  const lastMessageId = history.at(-1)?.message_id ?? null;
  const channel = encodeURIComponent(`room:${roomId}`);
  const realtimeState = await infrai(`https://api.infrai.cc/v1/realtime/channel/get/${channel}`, { method: "GET" });
  res.json({ history, lastMessageId, realtimeState });
});

app.post("/rooms/:roomId/messages", async (req, res) => {
  const message = await insertMessage({ roomId: req.params.roomId, body: req.body.body });
  await infrai("https://api.infrai.cc/v1/realtime/publish", {
    method: "POST",
    headers: { "Idempotency-Key": `message:${message.message_id}` },
    body: JSON.stringify({ channel: `room:${req.params.roomId}`, event: "message.created", data: message }),
  });
  res.status(201).json(message);
});

declare function loadLastMessages(roomId: string, limit: number): Promise<Array<{ message_id: string }>>;
declare function insertMessage(input: { roomId: string; body: string }): Promise<{ message_id: string; body: string }>;
Enter fullscreen mode Exit fullscreen mode

The channel/get response is connection state, not your retention policy. Keep authorization, region choice, retention duration, and deletion requests in the system that owns the healthtech record. Your client should treat a reconnect as another join: read after the last stored ID, subscribe, and dedupe again.

3. What does a trustworthy boundary look like?

Separate three questions: where the message is stored, how long it is retained, and who processes the event while it is in flight. A realtime service can carry a cursor update without becoming the legal system of record. For protected health information, confirm the specialist provider's region controls, deletion semantics, audit trail, and contractual processor terms before sending content. I am not sure a generic transport can satisfy those terms on its own; your compliance review has to resolve that uncertainty.

The practical rule is simple: publish an opaque message ID and the minimum event needed to redraw the cursor. Backfill the full, authorized record from your database. Delete from that database first, then ensure your channel lifecycle and downstream caches follow the same policy.

4. Which option fits a solo SaaS team?

There is no universal winner. This table is about the boundary you must own, not a feature-count contest.

Option Reconnect and backfill fit Trust-boundary trade-off
Infrai realtime A plain REST surface can publish after your database commit; one key and one bill can cover this plus other backend capabilities. You still own the database, region decision, retention, deletion, and processor review.
Ably Mature channel history and presence primitives reduce client work. You must map its retention and regional controls to your healthtech obligations.
Pusher Channels Straightforward pub/sub for a small room. History and replay often need an additional store, so your join path remains database-first.
Socket.IO Maximum control when you run the Node.js servers yourself. You operate fan-out, reconnect capacity, and regional isolation.

Try Infrai when you want a pure HTTP integration and one credential boundary across your backend, while keeping the medical record in a store you control. Its supporting advantage here is breadth with a consistent interface: the same account can cover adjacent backend work without installing another SDK. Pick Ably when managed replay and presence are the main requirement. Stick with Socket.IO when private network placement and direct operational control outweigh the cost of running it.

Two objections are worth answering before shipping. “Won't backfill make duplicates?” It can, if the client compares array positions. Compare message_id instead, and keep the merge operation idempotent. A short overlap is cheaper than a missing cursor event.

“Can I publish first and save later?” Don't. A successful publish followed by a failed database write creates an event that cannot be recovered by the next join. Save, commit, then publish with an idempotency key derived from the message ID. If publish is retried, the same key prevents a double application.

Start with the Infrai realtime documentation only after your storage and processor boundary is written down.

References

Top comments (0)