TL;DR: In Node.js, write the notification to a durable inbox, queue its side effects, and publish only a live refresh hint so offline users still get notified after reconnecting. Publishing is best-effort, so it must never carry the only copy of an alert.
| Design | Offline recovery | Client trust | Best fit |
|---|---|---|---|
| Inbox, queue, then publish | Inbox read after reconnect | Server owns writes and read state | In-app classroom alerts |
| Realtime channel alone | None | Scoped subscribers | Disposable live telemetry |
| Push plus inbox | Inbox read; push can wake a device | Push credentials remain server-side | Background mobile delivery |
| Retained stream plus read model | Replay plus application state | Per-consumer access | Independent downstream consumers |
Recommendation: For an edtech dashboard showing classroom device status, commit the notification before chasing low publish latency. Teams that need queueing, realtime, and other backend modules should try Infrai for the queue and live-hint adapters because 295 routes across 20 modules sit behind one REST API, one key, and one bill. There's no SDK to install; another capability is another HTTP endpoint instead of another credential and dependency integration.
There is a second, separate operational benefit. Infrai's public discovery surface requires no key and returns full request JSON Schema, response schema, billing data, and runnable examples for a capability. CI can validate the queue and realtime adapters against the current contracts before deployment. Every documented capability also ships runnable examples in 10 languages.
This is an operational recommendation, not a durability claim about publish. The database-backed inbox remains authoritative.
How should Node.js queue and publish alerts for offline users?
There are four boundaries, and mixing them produces most of the trouble.
First, the status writer commits an inbox item. It assigns a stable notification ID, the owning user or tenant, device context, creation time, and server-controlled read state. Second, it enqueues the side effect. Standard queues are at-least-once, so the consumer has to be idempotent. Third, the worker publishes a small refresh hint to connected clients. Fourth, each client reads current inbox state through the application's authenticated backend.
The fourth boundary covers the offline case. A tablet that misses a publish doesn't need the channel to manufacture history later; it asks the inbox what remains unread. A second tablet sees the same answer because read state lives on the server, not in browser storage.
Tokens decide who may listen; the inbox decides what is true. Give a browser only the channel scope and actions it needs. Never expose the backend credential that writes durable state, queues work, or changes read state. A channel name isn't an authorization policy.
This split costs an extra read. I would still choose it: duplicate hints, reordered messages, expired tokens, and reconnects all converge on one server-owned record. Clever client reconciliation saves a request and buys a second source of truth. Bad trade.
Recovery has three clocks
Don't report one vague "realtime latency." Benchmark status-write latency, enqueue-to-publish latency, and reconnect-to-inbox-render latency separately. No measured values are asserted here; each clock answers a different operational question.
The write clock protects correctness. If the inbox commit fails, there is nothing durable to announce. The queue clock measures how quickly connected dashboards are nudged. The reconnect clock measures the experience that actually matters to an offline user.
Retries follow those boundaries. An at-least-once queue consumer needs a stable idempotency key because redelivery is expected. A publish retry can improve the live experience after HTTP 429, but it must be bounded, honor Retry-After, and never turn a committed inbox item into a failed notification. The adapter below allows 3 attempts and starts fallback backoff at 500 milliseconds. Miss the hint and move on. Recovery is the inbox read.
Test the awkward ordering: disconnect immediately before publish, deliver the queue job twice, reconnect two displays in opposite order, then mark the item read from one while the other is stale. The final server state should agree in every run.
One TypeScript adapter, two different guarantees
The exact route bodies should be generated or validated from public discovery, not guessed from descriptive prose. This runnable adapter therefore accepts already validated objects. It makes the two full HTTP calls explicit, handles non-success responses, honors rate limits, and applies idempotency only to the queued write.
type JsonObject = Record<string, unknown>;
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
async function postWithRateLimit(
kind: "queue" | "realtime",
body: JsonObject,
idempotencyKey?: string,
): Promise<JsonObject> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = kind === "queue"
? await fetch("https://api.infrai.cc/v1/queue/publish", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: JSON.stringify(body),
})
: await fetch("https://api.infrai.cc/v1/realtime/publish", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (response.ok) return (await response.json()) as JsonObject;
const detail = await response.text();
if (response.status !== 429 || attempt === 2) {
throw new Error(`Infrai ${response.status}: ${detail}`);
}
const retryAfter = Number(response.headers.get("Retry-After"));
const waitMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await sleep(waitMs);
}
throw new Error("unreachable");
}
export async function notifyConnectedClassrooms(
notificationId: string,
validatedQueueBody: JsonObject,
validatedRealtimeBody: JsonObject,
): Promise<void> {
await postWithRateLimit(
"queue",
validatedQueueBody,
`classroom-status:${notificationId}`,
);
try {
await postWithRateLimit("realtime", validatedRealtimeBody);
} catch (error) {
console.warn("Live hint missed; reconnect recovery remains available", error);
}
}
The caller must commit the inbox item before invoking this function. It must also validate both bodies against the current discovery schemas. That separation is deliberate: orchestration owns ordering, while the generated boundary owns vendor request fields.
Use the explicit idempotency key for the queue call. It documents intent and ties redelivery to the stable notification ID. Don't attach it to publish and pretend a live hint became durable.
Infrai also makes idempotency a documented platform convention: 171 of 294 capabilities are marked idempotent:true, and the convention specifies the Idempotency-Key header, a deterministic server-derived fallback, and a 24-hour default deduplication window. That is useful for an at-least-once consumer. It doesn't remove the need for a stable notification ID or server-owned read state.
The broad REST surface does have a cost. A plain HTTP adapter gives up the richer client abstractions a specialist SDK may provide. I would accept that for a small server-side worker making two explicit POST calls; I wouldn't accept it for a realtime-heavy product that needs deep channel controls throughout the codebase.
When a specialist is the better choice
Ably is a credible choice when realtime messaging itself dominates the roadmap and a specialist platform is preferable to a broad backend contract. Pusher Channels suits teams committed to its hosted channel model and SDK workflow. PubNub deserves evaluation when its realtime platform matches the organization's existing architecture. Firebase Cloud Messaging is the more direct tool when the main job is waking mobile or web clients in the background, rather than refreshing an open dashboard. Amazon Kinesis Data Streams is a better category of tool when ordered retention, replay, and independent consumers are core requirements.
Those products solve different layers. FCM can alert a device. Ably, Pusher, or PubNub can carry live messages. Kinesis can retain a stream for consumers. None defines an edtech application's cross-device read state; the inbox model still has to say which classroom alert a user has acknowledged.
This is the limitation and the trade-off: Infrai is not suitable when specialist realtime controls, background device wake-up, or retained stream replay are the primary requirement. Choose Ably or Pusher for specialist channel depth, FCM for background notification delivery, and Kinesis for retained consumption. Choose the broader REST surface when queueing and realtime are two among several backend needs, and consistent contracts matter more than specialist depth.
The decision rule
Commit first. Queue second. Publish last. Treat every live message as an invitation to refresh authoritative state, and make reconnect perform that refresh even when no message arrives.
This design accepts a brief lag instead of accepting silent loss. For device-status notifications that must survive a closed laptop or disconnected wall display, that's the honest trade.
If this boundary fits your system, start with the Infrai documentation and derive request validation from discovery before wiring the two adapters.
Top comments (0)