Nodejs Queue Handoff: Publish Durable Alerts So Offline Users Get Notified
Nodejs Queue Handoff: Publish Durable Alerts So Offline Users Get Notified
Short answer: In a Nodejs application, queue the notification before trying to publish it; offline users still get notified because the durable inbox, rather than the live connection, owns delivery.
The important trade-off is simple: presence should be allowed to expire, while a notification should remain durable until the application records that it was delivered or acknowledged. Treating both as the same realtime message is how a shared workspace quietly loses the person who was offline when a comment arrived. Queue the notification as application state first, then attempt the live delivery; the live channel is an optimization, not the record of truth.
I design this boundary around an incident pattern rather than a transport demo. A collaborator edits a document, another collaborator is disconnected for 90 seconds, and the product must still show that mention later. A presence badge that says “online” after the lease expired is stale data. A mention that disappears because a socket was absent is a delivery bug. Those are different failures and deserve different storage and retry rules. In Nodejs, the event loop changes how the worker is scheduled, but it does not change this ownership decision.
That is the boundary.
Keep it boring.
The failure worth designing around is the gap between two acknowledgements. The application commits a notification intent, a worker claims it, and the live transport accepts the envelope. Before the worker can mark the row delivered, its process is restarted, the network path times out, or the connection disappears. On restart, the pending row is visible again. A design that assumes the first acceptance was final either loses the item or sends it twice; a design with a stable notification ID lets the client and server collapse the second attempt into the same user-visible event. This is why the queue record needs an ownership lease, why the consumer needs deduplication, and why “the socket was connected” is not an audit trail. The extra state is operational work, but it is work that can be inspected and repaired.
What should survive the disconnect?
The durable record needs an idempotency key, recipient, event type, a reference to the workspace object, creation time, and a delivery state. It does not need to preserve a socket. The queue worker can read that record after a reconnect, while the presence service can independently observe a heartbeat and replace the badge with “offline” when its lease ends.
That split also makes the user-visible contract honest. “Alex is online” is a current observation with a bounded freshness window. “Alex mentioned you in a document” is an application event with retention and read semantics. A transient connection can carry either signal, but it should not own either meaning.
The first trap is enqueueing only after a publish call succeeds. A broker or websocket gateway can accept a message and the process can still fail before the database transaction that records the notification commits. The opposite ordering has a different cost: a worker may publish twice after a timeout. At-least-once work plus an idempotent consumer is the defensible default here; exactly-once delivery is not a property I would infer from a successful network write.
How should a Nodejs queue publish alerts when users go offline?
The queue-first path gives the application a recoverable fact before it asks a connection to do something best-effort. In a real implementation, the comment transaction and notification intent should be committed together, or an outbox row should be committed in the same transaction. A separate worker claims pending rows, publishes a small envelope, and advances the row only after a defined acknowledgement. If the recipient has no active session, the worker leaves the item pending for inbox retrieval or a later live session.
Here is the critical part of that path in Go. The interfaces are deliberately generic because the contract matters more than a particular queue or realtime product.
package notify import ( "context" "time" ) type Notification struct { ID string Recipient string Workspace string Kind string ObjectID string CreatedAt time.Time } // Store and LiveTransport are adapters around durable storage and a live channel. type Store interface { ClaimPending(ctx context.Context, limit int) ([]Notification, error) MarkDelivered(ctx context.Context, id string, at time.Time) error Release(ctx context.Context, id string, reason string) error } type LiveTransport interface { Publish(ctx context.Context, n Notification) error } func DeliverBatch(ctx context.Context, store Store, live LiveTransport, limit int) error { items, err := store.ClaimPending(ctx, limit) if err != nil { return err } for _, item := range items { err := live.Publish(ctx, item) if err != nil { if releaseErr := store.Release(ctx, item.ID, err.Error()); releaseErr != nil { return releaseErr } continue } if err := store.MarkDelivered(ctx, item.ID, time.Now().UTC()); err != nil { return err } } return nil } The worker must claim with a lease or equivalent ownership token. Otherwise two workers can process the same row concurrently. The receiver must also deduplicate by ID, because the publish acknowledgement and the delivery-state update are separate operations. A retry after the first succeeds can therefore produce a duplicate attempt, even though the user should see one notification.
This code intentionally does not mark a notification read. Delivery means the application handed the envelope to the live transport or made it available through the inbox contract. Read state belongs to an explicit user action, and it should not be guessed from a websocket acknowledgement.
What does an offline user actually receive?
An offline user needs a second path: on session establishment, fetch the unread durable notifications for that user and workspace, order them by the application timestamp, and then subscribe to new events. The ordering boundary matters. If the initial fetch and subscription are arranged carelessly, an event can land between them and be omitted from the client view. A cursor, sequence number, or transactional snapshot gives the reconnect path a point from which to resume.
Presence does not belong in that backlog. The client can request current presence after authentication and then listen for changes; it should not replay ten-minute-old heartbeats as though they were current. A useful rule is that a presence lease is valid only until its expiry plus a deliberately small clock-skew allowance. The exact interval is a product decision tied to network conditions and the cost of showing stale availability, not a universal constant.
For a shared workspace, I would keep the notification payload narrow: notification ID, workspace ID, event kind, object ID, and a revision or timestamp. The client can fetch authorized details rather than trusting arbitrary text embedded in a broadcast. Authorization is checked again on fetch, because a recipient’s access to a document can change while a notification is waiting.
Which failure should page the on-call engineer?
A dashboard showing “connected clients” is a weak answer. It can be green while the durable backlog grows, or red while every notification is being recovered through inbox reads. I want signals that expose the user contract: age of the oldest pending notification, retry counts by reason, claim lease expirations, duplicate suppression, reconnect fetch latency, and the proportion of live publishes followed by a valid acknowledgement. The alert question is blunt: what page fired, and which user-visible promise is currently at risk?
Keep the incident boundary explicit. A temporary live-channel outage should increase pending age and retries; it should not erase notifications. A storage failure should block creation of the durable intent and may need a higher-severity response because the system no longer knows what it owes. A presence stream failure should degrade the badge to an unknown or stale state rather than manufacture online users.
Testing should exercise the gaps between operations, not just the happy path. Insert failures after the durable write, after claim, after live publish, and before the delivery-state update. Reconnect a client between its initial inbox read and subscription. Send the same notification twice. Revoke workspace access while an item is pending. Those cases reveal whether the design has a recovery contract or merely a successful demo.
Where this advice stops applying
Queue-first delivery is the wrong shape for data that has no value after the connection closes, such as a typing indicator or a rapidly changing cursor. It is also insufficient for urgent external escalation, where a separate policy may require provider-specific acknowledgement, rate limits, and human escalation. For ordinary in-app mentions, task assignments, and review activity, durable intent plus a live hint is a reasonable boundary.
The decision is therefore not “which realtime transport is fastest?” It is “which state must remain true when nobody is connected?” Presence answers: very little, and only for a bounded lease. Notifications answer: enough to reconstruct an unread inbox. Once that distinction is encoded in storage, queue retries, reconnect reads, and observability all become easier to reason about.