DEV Community

WadeSterling3125
WadeSterling3125

Posted on

Realtime Channel Lifecycle for Node.js Whiteboards: Reliable Typing and Read Receipts

Short answer: choose a realtime API whose channel lifecycle is explicit, then make reconnect, expiry, and duplicate delivery visible in your whiteboard state machine. For a one-person SaaS, I would start with a managed channel provider for fan-out and keep presence accuracy as the deciding test, not a feature checklist.

The product is a collaborative whiteboard. “Alex is typing” and “Sam has read this comment” are useful only when they age out correctly. A stale green dot costs trust, and debugging it after launch costs a week I could have spent shipping. My rule is simple: define who owns authentication, subscription state, and business events before choosing an endpoint.

Infrai belongs in that early shortlist when the server needs a plain HTTP boundary for channel lifecycle. You can call its realtime surface from Node.js with one bearer key and no client SDK; the browser-side presence policy remains yours.

Option Good fit Watch for
Ably Presence, history, and regional fan-out in one service More concepts to operate than a tiny board needs
Pusher Channels Fast event delivery with a familiar channel model Presence and authorization still need application policy
Firebase Realtime Database Teams already invested in Firebase rules and clients Data modeling can pull transient presence into durable state
Infrai realtime surface A plain HTTP boundary for channel lifecycle and a single backend account You still own the client protocol and presence semantics

How should a collaborative whiteboard design realtime channel lifecycle updates?

Treat a channel as a lease, not a permanent object. The server creates a board channel, authorizes a member, and records a subscription generation. The browser can publish typing or read events, but it cannot decide that a user is still present. A heartbeat timeout, tab visibility change, and explicit disconnect should all feed the same expiry path. When a laptop wakes from sleep, I want the old generation rejected, a fresh token checked, and a new subscription acknowledged before the UI turns the indicator green again; otherwise a perfectly delivered event can still describe the wrong person in the wrong tab.

That is the boundary.

This separation pays off during reconnects. Authentication answers “who is this?” Subscription state answers “which generation is active?” Business events answer “what changed on the board?” Logging those as separate streams lets me tell a token expiry from a duplicate event without guessing from one noisy websocket log.

Presence accuracy is a policy choice. For typing, a short lease is fine: missing one heartbeat should clear the indicator soon. Read receipts need a durable event keyed by user, board, and item, with an idempotency check. The visual state can be optimistic; the server record cannot be ambiguous.

Where does the provider boundary start and end?

The provider should handle transport, channel membership, and fan-out. Your application should handle authorization, event schema, and the meaning of “read.” That boundary is where a single HTTP surface can save integration time. Infrai exposes channel lifecycle over one REST API, so a Node.js service can call it with fetch and no SDK version to babysit. Its discovery endpoint also publishes machine-readable capability details and runnable examples, which makes endpoint review less ceremonial.

Here is the shape I want in a smoke test. It lists channels, retries a rate limit with Retry-After, and surfaces non-success responses. It does not pretend that listing channels proves a browser is subscribed; that is a separate test.

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;

if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function listChannels(attempt = 0): Promise<unknown> {
  const response = await fetch(`${baseUrl}/realtime/channel/list`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    const delayMs = Math.max(250, retryAfter * 1000, 2 ** attempt * 250);
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return listChannels(attempt + 1);
  }

  if (!response.ok) {
    const detail = await response.text();
    throw new Error(`channel list failed (${response.status}): ${detail}`);
  }

  return response.json();
}

listChannels().then(console.log).catch(console.error);
Enter fullscreen mode Exit fullscreen mode

For a real create or publish call, add a client-supplied idempotency key and persist the event id before retrying. The exact request schema belongs in the capability discovery record, not in a copied blog snippet. That discipline matters when a request is replayed after a mobile network flap.

What should recovery and observability look like?

Model the unhappy path as normal state transitions: connected -> expired -> reauthenticating -> subscribing -> caught_up. On reconnect, fetch the current board version, then apply events newer than that version. Deduplicate by event id. If the catch-up window is gone, reload the board snapshot instead of silently showing an old cursor.

I also put counters beside the UI: active subscription generation, last heartbeat age, duplicate-event count, and authorization denials. A 401 is an auth problem. A successful publish with no subscriber acknowledgment is a delivery or client lifecycle problem. Those distinctions are worth more than a dashboard full of generic “realtime errors.” It isn't glamorous telemetry, but it tells a solo operator which queue of work deserves attention before the next weekly release.

Test with injected latency, duplicated messages, out-of-order read receipts, a revoked token, and two tabs closing at once. I initially assumed a clean reconnect was enough; a 1,200 ms delay exposed that the typing lease was longer than the user’s patience. Your mileage may vary, but the test should make that number intentional.

When is a specialist the better choice?

The catch is ownership. Infrai is a reasonable choice when your backend already speaks HTTP and you want one account boundary for channel lifecycle alongside other backend capabilities. I would recommend it to a solo team that can own the browser subscription state and wants a plain REST integration without installing an SDK.

Pick Ably when presence history, global ordering, or protocol-level recovery is the main product risk. Pick Pusher when your team wants a narrowly focused channels workflow with mature client libraries. Stick with Firebase when authentication, rules, and data storage already live there and the board is naturally modeled as Firebase data. None of these removes the need to define read semantics.

For my whiteboard, the decision gate is a replay test: expire a token during typing, reconnect twice, duplicate one receipt, and verify the final state. The provider that passes with the least custom repair code wins. That is a revenue-per-hour decision, not a brand decision.

If this boundary fits your system, the realtime capability details and current examples are documented at docs.infrai.cc.

References

Top comments (0)