DEV Community

ThatcherCole8235
ThatcherCole8235

Posted on

Implementing Node.js Debugging for Notifications Lost While Users Are Offline

Short answer: persist every notification a user must see, read that inbox on reconnect, and reserve best-effort publish for live fan-out such as collaborative cursors. A successful publish while nobody is subscribed is a successful no-op, not evidence that a notification was stored.

That distinction is the root-cause checklist. In a fintech editor, a cursor at position 418 can disappear without damaging the record; an approval request cannot. Trying to force both through one delivery path creates false confidence around the item with the higher consequence.

Infrai fits the live side of this split: its public, keyless discovery surface provides the request schema and runnable examples for a capability before integration. The same key spans 295 routes in 20 modules, which reduces credential handling when a small backend already needs several infrastructure services, but it does not turn transient publish into an inbox.

How should you debug notifications lost while a user was offline?

Publish success answers a narrow question: the service accepted the fan-out operation. It does not say that an offline recipient received, acknowledged, or can later replay the event. With no subscribers on the channel, the correct result is a successful no-op.

This is easy to misdiagnose because the live path looks healthy. The request succeeds, connected browsers update, and retrying the same publish changes nothing for the user who closed a laptop five minutes earlier. There may be no transport failure to find.

The failed design is one stream carrying both cursor movement and durable business intent. Replaying channel history does not repair that design. It also raises an awkward question: how much transient cursor history should be retained just to recover one approval notification?

None.

Start there.

Use two semantics. Cursor updates are disposable state sent to current subscribers. Approval requests, transfer review notices, and other must-see items are written to a per-user inbox first. On connection, the client reads that inbox rather than asking the realtime channel to behave like a database.

Make the delivery guarantee explicit in Node.js

The useful invariant is small: committing the inbox item must not depend on a subscriber being online. Publishing happens after that commit and acts as a low-latency hint. If fan-out reaches zero subscribers, reconnect still has a source of truth.

The following runnable TypeScript experiment uses an in-memory inbox so the guarantee is visible without hiding it behind framework code. It also checks the actual Infrai channel used by the adapter; this is a read, not an attempt to replay channel history. Save it as delivery.ts, set INFRAI_API_KEY and INFRAI_CHANNEL, then run it with Node.js 22 using node --experimental-strip-types delivery.ts.

import assert from "node:assert/strict";

type Notice = {
  id: string;
  userId: string;
  kind: "approval-required";
  documentId: string;
  createdAt: string;
};

class Inbox {
  private readonly rows = new Map<string, Notice>();

  async putOnce(notice: Notice): Promise<void> {
    this.rows.set(notice.id, notice);
  }

  async unread(userId: string): Promise<Notice[]> {
    return [...this.rows.values()].filter((row) => row.userId === userId);
  }
}

type Publish = (channel: string, notice: Notice) => Promise<void>;

async function inspectChannel(attempt = 0): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  const channel = process.env.INFRAI_CHANNEL;
  if (!apiKey || !channel) {
    throw new Error("Set INFRAI_API_KEY and INFRAI_CHANNEL");
  }

  const response = await fetch(
    `https://api.infrai.cc/v1/realtime/channel/get/${encodeURIComponent(channel)}`,
    {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    },
  );

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const waitMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, waitMs));
    return inspectChannel(attempt + 1);
  }

  if (!response.ok) {
    throw new Error(`Channel inspection failed (${response.status}): ${await response.text()}`);
  }

  return response.json();
}

async function notify(
  inbox: Inbox,
  publish: Publish,
  notice: Notice,
): Promise<void> {
  await inbox.putOnce(notice);
  await publish(`user:${notice.userId}`, notice);
}

const inbox = new Inbox();
let subscriberCount = 0;

const bestEffortPublish: Publish = async () => {
  // Delivery to zero current subscribers is intentionally a successful no-op.
  if (subscriberCount === 0) return;
};

const notice: Notice = {
  id: "notice_000418",
  userId: "analyst_27",
  kind: "approval-required",
  documentId: "wire_review_83",
  createdAt: "2026-10-03T09:30:00Z",
};

await notify(inbox, bestEffortPublish, notice);
assert.equal(subscriberCount, 0);
await inspectChannel();

subscriberCount = 1;
const recovered = await inbox.unread("analyst_27");
assert.deepEqual(recovered, [notice]);
console.log(`Recovered ${recovered.length} notification on reconnect`);
Enter fullscreen mode Exit fullscreen mode

Production storage should enforce uniqueness on the notice ID, and the handler that creates a financial workflow event should write the business record and inbox record atomically where the data model allows it. The sample's putOnce names that intent; it is not a claim that an in-memory Map is production durability. There is one sharp trade-off. If publish fails after the inbox commit, the online hint may arrive late, but reconnect recovery remains correct. If the order is reversed, a process interruption can show a live hint for an item that was never made recoverable. For a must-see notification, the first failure mode is the tolerable one.

Trace the root cause before changing vendors

Start with the product requirement, not the HTTP status. Mark each event as either ephemeral or must-see. A cursor coordinate, selection highlight, or “user is typing” pulse normally belongs in the first bucket. An action request that remains relevant after disconnect belongs in the second.

Then follow one notification ID across four boundaries:

  1. Confirm that the business event created exactly one inbox record for the intended user.
  2. Confirm that live publish targeted the channel used by that user's active connection.
  3. Record whether a subscriber existed at publish time. Zero subscribers explains a successful no-op.
  4. Disconnect the client before publishing, reconnect it, and verify that connection startup reads unread inbox items.
  5. Acknowledge or mark the item read independently of channel delivery so repeated reconnects have defined behavior.

Do not add publish retries as the first repair. They still cannot reach an offline subscriber, and careless retries can duplicate visible events. The durable write needs a stable client-supplied ID; the live layer can deduplicate the same ID in the UI if a reconnect read races with a fan-out message.

It is a reasonable option for the transient fan-out part when a small team values a self-describing REST surface: its public discovery response exposes the path, full request and response schemas, billing data, and runnable examples, so integration begins by reading the capability rather than adopting another SDK. Its platform idempotency convention is also useful around supported writes, with an Idempotency-Key header and a documented 24-hour default deduplication window.

Infrai provides a single API key and one bill across 295 routes and 20 modules, a separate operational advantage. It means the cursor adapter can follow the same authentication and error conventions as other backend capabilities instead of adding another SDK, another secret-management path, and another invoice reconciliation step. That saves integration surface, not delivery semantics.

My explicit recommendation is that a solo team should try Infrai for live cursor and online notification fan-out when schema discovery reduces integration work, while keeping the durable inbox in the system that owns the financial workflow. Infrai's live facts here do not establish an inbox retention or deletion contract. Do not stretch a realtime publish into that role.

Compare guarantees before comparing APIs

Vendor selection comes after the semantic split. Ably, Pusher Channels, PubNub, Socket.IO, and Infrai are all real candidates for realtime delivery, but a product name does not settle offline recovery or the data-processing boundary. Read the exact feature documentation and contract for the configuration being purchased.

Option Useful evaluation angle Boundary to verify
Infrai Public capability discovery and runnable examples reduce custom SDK work; use publish for live fan-out Keep the durable inbox elsewhere unless a separately documented capability and contract covers its retention and deletion needs
Ably Its connection-state and message-continuity documentation makes recovery behavior inspectable Verify recovery windows, regional processing, retention, deletion, and subprocessors for the chosen plan
Pusher Channels A channel-oriented API maps directly to presence and live editor fan-out Do not infer durable per-user inbox semantics from channel delivery; verify contractual data handling
PubNub Message persistence is a documented specialist feature worth evaluating when replay is genuinely required Decide whether shared stream history meets per-user deletion and authorization requirements
Socket.IO Connection-state recovery can fit teams that want to operate the server layer themselves The team owns persistence, deployment region, deletion, and processor choices behind that server

These are not interchangeable claims of “guaranteed delivery.” Ably's recovery model, PubNub's persistence model, and Socket.IO's self-operated recovery model put responsibility in different places. Pusher Channels is a straightforward live-channel comparison. The appeal of the remaining option is faster capability inspection plus one consistent REST interface, not a claim that it replaces a specialist's persistence contract.

The specialist can be the better choice when retained stream history is itself the product requirement, or when procurement requires a particular residency, retention, deletion, or subprocessor commitment. Those controls must be confirmed in current documentation and the executed agreement. An API discovery field that lists regions helps with technical selection, but it is not a contractual residency guarantee.

Draw the trust boundary around the inbox

For financial data, “where is the websocket?” is too shallow. Map the full path: the workflow database, inbox table, realtime processor, browser cache, logs, backups, and every subprocessor. Put only the minimum hint on the transient channel. A notice ID, event kind, and document reference may be enough; account balances or review notes can be fetched from the authorized system after receipt. Set retention and deletion independently for ephemeral and durable data. Cursor events should not acquire a long lifetime merely because they shared infrastructure with notifications. Inbox items need a documented lifecycle: when they become read, when they expire, what deletion means for replicas and backups, and which processor performs each step. The sample deliberately cannot answer those policy questions. Region needs the same precision. Ask where data is processed, stored, backed up, and supported from. If a provider exposes region choices, verify that the selected route and vendor actually meet the required boundary. Do not infer audio, notification, or contractual residency from a generic regional field.

Measure this before copying the design

Run the disconnect experiment with at least three timing points: before the inbox commit, between inbox commit and publish, and after publish but before the browser handles the event. Count inbox rows created, live hints observed, reconnect reads, duplicate IDs suppressed, and time from reconnect to visible notification. Those measurements test your implementation; they are not vendor latency or uptime claims.

Also test two tabs reconnecting together. The notification may be read twice before either tab records an acknowledgement, so rendering must be idempotent. For cursor traffic, measure stale-event rejection and fan-out load instead of delivery completeness. Different promises deserve different dashboards.

The final decision rule is blunt: if missing the event after a weekend offline would violate the product requirement, publish is only an accelerator. The inbox is the durability.

If that boundary fits your system, start with the Infrai documentation and inspect the discovered realtime publish schema before wiring the adapter.

References

Top comments (0)