DEV Community

ChrysostomHayes8537
ChrysostomHayes8537

Posted on

Realtime Channel vs Message Queue Explained: Node.js Notification Durability

A fintech editor should send cursor movement over a realtime channel and put consequential in-app notifications through a durable queue before attempting live delivery. Use the queue as the record and the channel as the accelerator. That is the least complex design that preserves a fast collaborative feel without pretending every event deserves recovery.

TL;DR: a channel publishes now to whoever is connected now. If nobody is listening, it delivers nothing by design. A queue can survive application downtime and a user's offline period, though delivery may happen later. Notifications that matter need both paths; transient cursor coordinates usually need only the first.

Should notification durability use a realtime channel or message queue?

The word "realtime" describes timing, not durability. Consider two events from the same fintech document:

  • cursor.moved says an analyst's caret is beside the revenue field. Its value decays in seconds. Replaying 400 old coordinates after a laptop wakes up would be actively wrong.
  • approval.requested says a controller must review a changed payout instruction. Missing it because the browser disconnected is unacceptable. It needs a durable record and explicit processing.

Publishing either event to a channel can make an online editor react immediately. Yet publishing to an empty channel is a successful no-op, not a delayed delivery. A queue reverses the important property: work remains available while the consumer or user is away, at the cost of asynchronous delivery and the machinery needed to consume, acknowledge, retry, and deduplicate it.

That split is the design.

Cursor state belongs to the live session; a notification belongs to the user's pending work. Do not turn every mouse twitch into durable backlog just because a queue exists. I would accept a dropped cursor packet long before I would accept an approval request that vanished while its recipient was offline; the trade-off follows the consequence of loss, not the appeal of one transport.

A minimal Node.js dual path

The data flow is short. Accept an important notification, assign its stable ID, publish it to the durable queue, and only then publish the corresponding live event to currently connected clients. A worker consumes the queued item idempotently and maintains unread state. On reconnect, the client reads that state rather than asking a transient channel to reconstruct history. Two paths, two jobs, one stable event ID.

The example below uses the two verified publish routes through one REST base URL. The public discovery response supplies the full request JSON Schema for each capability, so the program takes those validated request objects from the surrounding application rather than inventing payload fields here. It is runnable with npx tsx publish.ts; set INFRAI_BASE_URL to the documented API origin, set INFRAI_API_KEY, and provide QUEUE_BODY_JSON plus REALTIME_BODY_JSON from those discovery schemas.

import { randomUUID } from "node:crypto";

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

type JsonObject = Record<string, unknown>;

function requiredJson(name: string): JsonObject {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is required`);
  return JSON.parse(value) as JsonObject;
}

async function post(
  url: URL,
  body: JsonObject,
  idempotencyKey: string,
): Promise<JsonObject> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });

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

    if (!response.ok) {
      throw new Error(`${url.pathname} failed (${response.status}): ${await response.text()}`);
    }
    return await response.json() as JsonObject;
  }
  throw new Error(`${url.pathname} remained rate-limited after 5 attempts`);
}

const eventId = process.env.EVENT_ID ?? randomUUID();
const queued = await post(
  new URL("/v1/queue/publish", baseUrl),
  requiredJson("QUEUE_BODY_JSON"),
  eventId,
);
const live = await post(
  new URL("/v1/realtime/publish", baseUrl),
  requiredJson("REALTIME_BODY_JSON"),
  eventId,
);
process.stdout.write(`${JSON.stringify({ eventId, queued, live })}\n`);
Enter fullscreen mode Exit fullscreen mode

Order matters.

The queue call completes before realtime publication. Swapping those calls creates a small but real loss window: a connected browser could render the notice, crash before saving its checkpoint, and find no durable copy after reconnecting. If queue publication fails, the live notification is not sent. If the queue succeeds and live publication fails, the worker still has the durable item and can update pending state; the channel remains an acceleration path rather than the source of truth.

The demo uses a generated UUID, but a production producer should accept a stable event or idempotency key from the operation that caused the notification. Queue redelivery is normal for at-least-once systems. The consumer must make a repeated ID harmless rather than converting a retry into two badges, two emails, or two ledger-adjacent actions.

Delivery guarantees are a product decision

"Exactly once" is a tempting label and a poor starting requirement. The editor actually needs three narrower decisions: which events may disappear, which may repeat, and which ordering relationships users can observe.

For cursor coordinates, best-effort fan-out is usually correct. The next coordinate supersedes the previous one, so loss is cheap and replay has negative value. For approval.requested, choose durable, at-least-once processing plus idempotent consumption. If two approvals for the same document may be created concurrently, define an application key such as the approval ID rather than relying on transport order.

There is another boundary people skip: durable delivery to a worker does not prove that a browser displayed the notification. If product requirements say "unread until viewed," store read state separately. Queue acknowledgement, successful channel publication, and human acknowledgement are three different events.

Keep the payload small. Put identifiers and the event type on the transport, then load authorized document state at consumption time. A cursor packet can be stale almost immediately; a financial notification can expose sensitive context if copied into logs, retry stores, and browser caches. These two event classes should not inherit one retention policy by accident.

Comparing the real options fairly

Choose products after choosing the guarantee. The table is intentionally about fit, not a universal ranking; deployment model, regions, quotas, and current terms still need verification against each vendor's documentation.

Product Natural role in this design Boundary to account for
Redis Pub/Sub Low-latency channel for disposable cursor updates Its documented delivery semantics are at-most-once; a disconnected subscriber cannot recover a missed message.
BullMQ Redis-backed job queue for durable notification work Workers must tolerate retries, and operating Redis remains part of the system design.
Amazon SQS Managed durable queue for notification processing Standard queues use at-least-once delivery, so consumer idempotency is mandatory.
Ably Managed channel layer for connected clients Treat channel fan-out and the application's durable notification record as separate responsibilities unless the selected feature explicitly guarantees the recovery window you need.
Pusher Channels Managed channel fan-out for browser sessions It solves the live path; verify history and recovery requirements independently rather than inferring them from a successful publish.
PubNub Managed realtime messaging for connected applications Match its configured persistence and replay behavior to the required recovery window; do not equate channel availability with an application inbox.
Socket.IO A Node.js-oriented transport layer when the team wants to own the server path It is not a durable notification queue by itself, so offline recovery needs another store.
Liveblocks Collaboration-focused infrastructure for editor presence and shared state It fits collaborative UI concerns; durable business notifications still deserve an explicit record and acknowledgement model.
Supabase Realtime Realtime change and broadcast features alongside a Postgres-centered stack It is a natural candidate when Postgres already anchors the product, but queue processing semantics must be designed separately.
Infrai One REST surface can cover realtime publishing and queue operations alongside 295 routes across 20 modules under one key Its breadth reduces integration work, but the application still owns event classification, idempotency, unread state, and the choice of delivery guarantee.

For a solo builder, operational ownership often decides the shortlist. BullMQ is attractive when Redis is already a deliberate dependency and queue behavior belongs inside the Node.js application. SQS shifts queue operations to AWS but brings cloud-specific configuration. Ably and Pusher Channels focus the live-client side. Redis Pub/Sub is direct and familiar, yet it should not be mislabeled as a durable inbox.

The broad single-contract option is useful when the same product will soon add adjacent backend capabilities and another SDK, credential, and billing integration would be meaningful overhead. Its limitation is specialization: it is not a fit when a team needs a collaboration-specific state model from Liveblocks, already operates Redis for BullMQ, or has standardized deeply on AWS and SQS. Switching costs are real either way, so keep the domain event shape and idempotency rules behind a narrow adapter. There is no universal winner here.

What should ship first?

Start with two event classes, not a generalized event platform. Send cursor movement directly to the channel with a short-lived connection token and no replay expectation. Send approval.requested to durable storage, process it idempotently, and use the channel only to reduce the time an online user waits.

Then test the failures that define the contract. Disconnect the browser before publishing an approval, reconnect it, and confirm one pending notification appears. Deliver the same queue item twice and confirm the unread count changes once. Stop the consumer, publish several items, restart it, and verify they drain. Finally, flood cursor updates and confirm they never enter the durable backlog.

Operationally, watch queue age, retry count, dead-letter volume, connected subscribers, and reconnect frequency as separate signals. Alerting on channel publish success alone says almost nothing about notification durability. Set retention from the product's recovery promise and compliance needs, not from a vendor default. Document who can replay a notification and how its read state is audited.

This is enough architecture for the first production version. More machinery should answer an observed requirement: cross-region recovery, strict per-document ordering, longer replay, or a compliance retention rule. Until one of those exists, the durable-record-plus-live-accelerator model keeps the correctness boundary visible and the cursor path fast.

Further reading

References:

Top comments (0)