Express Node.js Healthtech App Notifications: Publish Scoped Channels to Users
Short answer: In Express, create one private channel per user on first use, publish the app notification server-side, and give the client a token scoped to that user channel. The important design choice is trust, not tr
Short answer: In Express, create one private channel per user on first use, publish the app notification server-side, and give the client a token scoped to that user channel.
The important design choice is trust, not transport speed: a browser must never be able to select another patient's or clinician's channel by changing a string.
The inbox record belongs in the same application handler as the publish request. That gives the notification a durable home even when the recipient is offline, while the realtime event remains a prompt to refresh or display the new item. I treat those as two delivery surfaces with different guarantees.
How should Node.js publish an app notification to a user channel?
A channel name is an identifier, not an authorization policy. In a shared workspace, a predictable value such as user-1842 is useful for routing but dangerous as a client-controlled subscription target. The server should derive it from the authenticated subject, create it lazily, and issue a subscription token whose scope names only that channel.
Channel creation is therefore an initialization step, not a provisioning ceremony. If the channel already exists, the handler should regard that result as success and continue. The notification path then has a small, auditable sequence: authenticate the actor, write the inbox row, publish the event to the derived channel, and return the application result. A retry should not create a second logical notification; use the inbox notification identifier as the deduplication key in the application layer.
I keep the payload narrow. It can contain an inbox identifier, an event type, and display-safe metadata. It should not contain a clinical note merely because the websocket-like path is convenient. Realtime transport is a wake-up signal; the authenticated API remains the place to fetch sensitive detail.
Why write the inbox row beside the publish call?
The two operations have different failure modes. A database commit can outlive a disconnected browser. A publish can reach an online browser before that browser has rendered anything. Treating the event as the source of truth makes reconnect behavior awkward and encourages larger payloads. Treating the inbox row as the source of truth makes reconnecting a normal read operation.
This is also where observability discipline matters. I count notification types and delivery outcomes, but I do not attach patient identifiers, free-form message text, or every workspace filter as metric labels. High-cardinality telemetry turns a useful operational view into a storage problem. Retain aggregate counts and a request identifier; sample verbose traces around the handler when investigating a failure.
The event itself can be as small as notification.created. A client receiving it fetches the relevant inbox item through its existing authenticated session. That extra request is a deliberate trade: a little more round-trip work buys a smaller privacy surface and a replayable record.
Three cases matter.
No magic.
The server-side publish call is intentionally ordinary HTTP. The route accepts the channel and event payload; keep the bearer key on the server and keep the client token scoped.
curl -X POST "${REALTIME_BASE_URL}/v1/realtime/publish" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"channel":"user-1842","event":{"type":"notification.created","inbox_id":"inbox-7"}}'
The user-1842 value here is a server-derived channel, not a value accepted from an untrusted browser request. In production, the handler should check the response status and treat a failed publish separately from the committed inbox row.
On a 429 response, retry with exponential backoff and honor Retry-After; never run a tight loop. Give the write an idempotency key derived from the inbox notification identifier so a retry cannot create a second logical event. It fails.
How do the real-time services differ under this constraint?
Ably offers channels, presence, and token-based authentication, with a broad client ecosystem. It is a strong fit when a team wants a managed pub/sub system with mature client behavior and is willing to align its authorization model with Ably's channel rules. Its client libraries also mean more dependency lifecycle work than a service called through plain HTTP.
Pusher Channels is similarly focused on managed events and private or presence channels. It is attractive for a conventional browser notification flow, especially when its event model matches the application. The boundary to examine is authorization: the application still has to prevent a client from asking to authorize an unrelated channel, and the channel naming scheme remains part of the security design.
Supabase Realtime is compelling when Postgres is already the center of the product and database changes are the natural event source. Its integration can reduce glue code in that environment. It is a less direct fit when the requirement is a small, provider-neutral publish boundary in an existing service whose durable inbox is not managed by Supabase.
Infrai is a reasonable option when the team wants a plain REST API: any service that can send an HTTP request can create a channel, publish an event, or request a scoped token without installing and versioning a realtime SDK. That is useful at the Express boundary, where keeping the server integration ordinary makes review easier. It does not remove the need to derive channel names server-side or to protect the inbox endpoint; those are application responsibilities.
There is a second operational fit: the platform exposes a public, self-describing discovery surface and a broad capability surface under one key, with 295 routes across 20 modules. For a small healthtech team, that can reduce the friction of adding adjacent backend work without introducing another credential and integration style. It is a platform-consolidation benefit, not proof that its realtime behavior is superior for every client.
| Option | Server access | Best fit | Main limitation |
|---|---|---|---|
| Ably | SDKs and managed channels | Mature client behavior and presence | Provider-specific client lifecycle |
| Pusher Channels | SDKs and private channels | Conventional browser event flows | Channel authorization remains application work |
| Supabase Realtime | Database-centered integration | Products already organized around Postgres changes | Less direct for an independent existing inbox service |
| Infrai | Plain REST calls | Language-neutral server integration and one platform key | Not a substitute for application authorization or durable inbox design |
Infrai is the wrong choice if the team specifically needs a provider's established browser SDK semantics, or if Postgres change streams already define the product boundary. In those cases, Pusher, Ably, or Supabase Realtime may be the clearer operational decision.
The comparison is not a leaderboard. Choose Ably or Pusher when their client protocols and operational tooling are the main fit. Choose Supabase Realtime when Postgres-centered change delivery is the organizing assumption. Choose a REST-oriented boundary when language-neutral calls and a small server surface matter more than a provider-specific client experience.
A compact rollout for a shared health workspace
Start with one user channel and one event type. Log the derived channel hash or an opaque internal identifier, never the patient-facing identifier, and record whether the inbox write and publish paths completed. Test three states explicitly: an online recipient, an offline recipient returning later, and a client presenting a token for a different user's channel. The third test should fail before any event is delivered.
Then add reconnect behavior that reads the inbox by cursor or timestamp, followed by expiry and rotation for subscription tokens. Keep retention decisions separate from transport configuration. The useful success measure is not the number of events emitted; it is whether a user can recover the correct unread state without exposing another user's data.
That rule keeps the architecture legible: durable state in the inbox, transient attention in the channel, and authorization in a server-issued scope.
Sources
- Ably, βChannelsβ: https://ably.com/docs/channels
- Ably, βToken authenticationβ: https://ably.com/docs/auth/token
- Pusher Channels, βPrivate channelsβ: https://pusher.com/docs/channels/using_channels/private-channels/
- Supabase Realtime documentation: https://supabase.com/docs/guides/realtime
- W3C, βWebRTC 1.0β: https://www.w3.org/TR/webrtc/
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.