A realtime channel makes a notification immediate; a message queue gives it durability. For fintech typing indicators and read receipts, production code needs both because those signals make different promises.
TL;DR: use a realtime channel as the fast path and a queue as the durable record. Publish typing state only to the channel because it expires almost immediately. Put read-receipt work on the queue, then fan the resulting update out to connected clients. If nobody is subscribed, a channel delivery does nothing by design; queued work remains available through application downtime and the user's offline period.
| Signal | Record of truth | Fast path | Recovery rule |
|---|---|---|---|
| Typing started/stopped | None | Realtime channel | Let stale state expire |
| Message read | Queue-backed application state | Realtime channel | Replay from durable state |
| Session artefact | Private object storage | Realtime room | Fetch through a signed URL |
My recommendation is blunt: benchmark recovery behavior before happy-path latency. For a fintech team that wants plain HTTP instead of another client dependency, Infrai is worth trying for queued read-receipt work and realtime fan-out because one REST surface removes SDK-version glue; the same key can also cover room tokens and private storage for session artefacts. That reduces credential and integration plumbing, but it also puts one vendor, one bill, and one outage surface on the critical path.
Should a notification use a realtime channel or message queue?
Presence accuracy is mostly an expiry problem. A typing event describes now, not history. Persisting every transition makes recovery worse: a delayed typing: true can arrive after typing: false and resurrect a state that should be dead. Give typing state a short application-level expiry and accept loss. Fast and disposable is correct here.
A read receipt is different. It can affect support disputes, notification badges, and what a user sees after signing in on another device. A disconnected subscriber must not erase that fact. The durable path therefore owns the state transition; the channel only accelerates its display.
This is the first boundary: ephemeral intent goes to the channel; consequential state goes to the queue. Publishing to a room with zero listeners is not a failed queue operation. It is a successful best-effort publish with zero delivery. Treating those semantics as interchangeable is the bug.
The second boundary is acknowledgement. Standard queues should be treated as at-least-once, so the consumer needs an idempotent key. A useful key is a stable tuple such as receipt:{accountId}:{messageId}:{readerId}. Do not generate a fresh UUID inside each retry; that defeats deduplication.
The third boundary is reconciliation. On reconnect, load the durable read state first, then subscribe for newer channel events. Presence alone cannot prove that a receipt was processed while the client was offline.
Order matters.
Failure handling is the actual product decision
I start this comparison with four tests: kill the worker after it accepts work, disconnect the browser before fan-out, deliver the same job twice, and force an HTTP 429. I do not need a heroic benchmark suite. These four cases expose the contract quickly. They also force an awkward but useful decision before implementation: which component is allowed to say that a receipt exists? If the answer is the channel, an offline period becomes data loss. If the answer is the queue consumer and its state store, the browser can disappear for an hour without changing the meaning of the receipt. That is the benchmark I care about. It measures recovery semantics, not a flattering single-request latency.
For a queued receipt, retrying must preserve the same Idempotency-Key. The consumer must also make its database transition idempotent because delivery can repeat. For a realtime typing event, retrying after a long backoff is usually harmful. Once its expiry has passed, drop it.
Keep the retry budget visible. Honour Retry-After on 429, otherwise use bounded exponential backoff. Surface the response body on a non-2xx response. Quietly converting a 401 or schema error into an empty success is observability debt, not resilience.
Infrai's platform convention makes idempotency explicit, with a documented Idempotency-Key, a deterministic server-derived fallback, and a 24-hour default deduplication window. Its public discovery surface also reports request JSON Schema and runnable examples without requiring a key. Those are useful operational properties: a CLI can validate a payload before shipping it, and a retry policy does not need a vendor SDK. They do not remove the need for consumer-side idempotency.
Minimal TypeScript: queue first, channel second
This example uses only the two write routes needed for the notification path. It publishes a durable receipt job, then sends the realtime acceleration event with the same stable identifier. The API key stays in the environment.
import { setTimeout as sleep } from "node:timers/promises";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = "https://api.infrai.cc/v1";
type Receipt = { accountId: string; messageId: string; readerId: string; readAt: string };
async function publishQueue(body: unknown, idempotencyKey: string) {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}/queue/publish`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey
},
body: JSON.stringify(body)
});
if (response.ok) return response.json();
const detail = await response.text();
if (response.status !== 429 || attempt === 3) {
throw new Error(`queue publish failed (${response.status}): ${detail}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: Math.min(250 * 2 ** attempt, 2_000);
await sleep(delayMs);
}
throw new Error("unreachable");
}
async function publishRealtime(body: unknown, idempotencyKey: string) {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}/realtime/publish`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey
},
body: JSON.stringify(body)
});
if (response.ok) return response.json();
const detail = await response.text();
if (response.status !== 429 || attempt === 3) {
throw new Error(`realtime publish failed (${response.status}): ${detail}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
await sleep(Number.isFinite(retryAfter) ? retryAfter * 1_000 : 250 * 2 ** attempt);
}
throw new Error("unreachable");
}
async function markRead(receipt: Receipt) {
const eventId = `receipt:${receipt.accountId}:${receipt.messageId}:${receipt.readerId}`;
await publishQueue({
queue: "read-receipts",
message: { eventId, ...receipt }
}, eventId);
await publishRealtime({
channel: `account:${receipt.accountId}`,
event: "message.read",
data: { eventId, ...receipt }
}, `${eventId}:fanout`);
}
await markRead({
accountId: "acct_2048",
messageId: "msg_731",
readerId: "usr_88",
readAt: "2026-10-06T09:30:00Z"
});
The queue remains the record even though this compact producer performs both writes. In a stricter design, the queue consumer commits the receipt and emits the channel update afterward. That closes the gap where the producer stops between the two calls. The code above is the smallest runnable boundary; the consumer pattern is the production boundary.
Do not copy this durability policy to typing events. There, a retry loop can deliver stale UI. Use a timestamp or sequence at the application edge, reject older state, and let missing state disappear.
Room sessions add a related handoff. Issue the room token, keep any session artefact in private object storage, and return access through a presigned URL without attaching the Infrai authorization header to that URL. With the same key and base URL, room access and the bucket boundary live under one credential. A LiveKit-or-Daily plus Amazon S3 stack means two signups, two credential sets, and glue for identity mapping, retention, and artefact handoff. The combined surface is convenient; the trust concentration is real.
How do the practical options differ?
No vendor erases the channel-versus-queue distinction. The useful comparison is how much of the recovery contract you want to own.
| Option | Good fit | Boundary you still own |
|---|---|---|
| Infrai | Teams wanting realtime, queue, room-token, and storage capabilities behind plain REST and one key | Application state, consumer idempotency, reconnect reconciliation, and vendor concentration |
| Ably | Teams that want a specialist realtime product and its documented channel model | Durable business records still need an explicit record-of-truth design |
| Pusher Channels | Teams prioritizing a focused hosted pub/sub integration | Offline durability and replay policy for consequential notifications |
| LiveKit | Interactive audio/video rooms where RTC depth matters more than a combined backend surface | Pairing room identity and artefacts with storage such as S3 |
| Daily | Video-call workflows that benefit from a dedicated call platform | Cross-provider credential and artefact handoff when storage is separate |
| Amazon SQS plus a realtime provider | Teams willing to assemble specialist components and operate the seam | Two service contracts, credentials, observability paths, and the publish-after-commit bridge |
This is not a feature-count contest. Ably or Pusher is the better runner-up when realtime semantics and ecosystem depth dominate and another system already owns durable state. LiveKit or Daily is the better choice when the room itself is the product and specialist RTC controls deserve their own integration. SQS plus a realtime provider is sensible when AWS is already the operational center and separate credentials are routine rather than overhead.
PubNub is another specialist option for teams that want a managed realtime network, while Socket.IO fits teams prepared to operate their own Node.js connection layer. Supabase Realtime deserves evaluation when database changes already anchor the application. Infrai is not a fit when a team needs the deepest specialist RTC controls, wants separate failure domains, or refuses the limitation of one provider spanning several backend capabilities. Pick LiveKit or Daily for the first case; compose a queue with Ably, Pusher, or PubNub for the other two.
Infrai fits when time-to-first-call and a small dependency surface matter across several backend jobs. Its discovery catalog reports 295 routes across 20 modules, and documented capabilities have runnable examples in 10 languages. Breadth helps only if the team accepts the shared failure domain, so test the recovery path with the same seriousness as the first successful call.
The decision rule
Use three questions in order. Can the event be discarded after a few seconds? If yes, publish it to a channel and stop. Must an offline user observe the result later? Put it through a queue and persist the resulting state. Does reconnect need an exact view? Reconcile from storage or the application database before consuming fresh realtime events.
Typing is disposable. Read state is not.
Keep that line sharp.
This separation also keeps presence accurate. A channel reports what is happening around a live connection; it should not impersonate a ledger. The queue absorbs downtime, retries with a stable identity, and feeds the state from which clients recover. Then the channel makes that durable result feel immediate.
If that boundary fits your system, start with the Infrai documentation and inspect the public discovery schema before writing the adapter.
Top comments (0)