Short answer: write each logistics notification to an inbox table before publishing it to a live channel. The inbox is the durable record; the channel is only the fast path. On reconnect, a client asks for inbox rows after its last cursor, and read state is changed on the server so a phone and a dispatch terminal converge.
This ordering also identifies the dominant observability cost before any vendor is chosen. A typing signal can arrive many times during one conversation, while a read receipt changes durable state once. Retaining every typing event, every delivery attempt, and high-cardinality labels such as user_id, shipment_id, and connection_id turns transient coordination into stored telemetry. Keep the final notification and read transition. Sample operational traces, aggregate the counters, and expire the typing noise.
What is the bill actually made of?
Start with event counts, not the channel invoice. Suppose a dispatch conversation has 12 typing updates, one notification, one delivery acknowledgement, and one read transition. If all 15 records are logged with three identifiers, the observability system stores 15 events and creates potentially expensive label combinations for a user action whose durable business state needs only the notification and the read transition.
The arithmetic is deliberately plain:
- retained event volume = event count x average encoded bytes x retention period;
- indexed cardinality grows with distinct label combinations, not merely line count;
- reconnect traffic = missed durable rows, not every transient event emitted while the client was away.
For capacity planning, measure the encoded byte distribution in the actual system and insert its median and tail values into that first equation. No defensible byte figure is available before that measurement. The useful design change is independent of the result: do not place typing indicators in the durable inbox, and do not preserve their full payloads as long-lived logs.
Small data wins.
Typing is an expiring hint. A read receipt is account state. A logistics alert such as “dock changed for shipment S-1042” is a durable fact the user must be able to recover after a tunnel, elevator, or device sleep breaks the connection. Treating those three messages alike inflates retention and makes recovery ambiguous.
How should an API recover durable notifications for offline users?
Before binding an adapter, inspect its machine-readable contract. The first command calls the unified REST option's public discovery surface through a deployment-provided base URL; authentication is included to demonstrate the same environment-variable convention used by authenticated calls. --fail-with-body surfaces a 4xx response, while curl's bounded retry behavior covers transient responses, including HTTP 429 and its Retry-After instruction. The discovery response describes 295 routes across 20 modules, including path and full request and response schemas, so an integration can generate the publish request from the declared path instead of guessing fields.
curl --request GET \
--url "$INFRAI_BASE_URL/discovery" \
--header "Authorization: Bearer $INFRAI_API_KEY" \
--fail-with-body \
--retry 5 \
--retry-all-errors \
--retry-max-time 60
curl --request POST \
--url http://localhost:3000/notifications/sync \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: notif-S-1042-dock-change-v3' \
--data '{"operation":"create","user_id":"dispatcher-27","notification":{"id":"notif-S-1042-dock-change-v3","type":"dock_changed","shipment_id":"S-1042","dock":"D7"}}'
curl --request POST \
--url http://localhost:3000/notifications/sync \
--header 'Content-Type: application/json' \
--data '{"operation":"backfill","user_id":"dispatcher-27","after":"cursor-00841","limit":100}'
curl --request POST \
--url http://localhost:3000/notifications/sync \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: read-notif-S-1042-dock-change-v3' \
--data '{"operation":"mark_read","user_id":"dispatcher-27","notification_id":"notif-S-1042-dock-change-v3"}'
The remaining calls define one application-owned synchronization contract; they are a proposed logistics-service interface, not vendor routes. Keeping create, backfill, and mark-read operations behind one route makes each action explicit while leaving the cursor and read state under server control. In an implementation, validate the discovered publish schema at build time and map the committed inbox event into that schema. This preserves a crucial boundary: the database transaction defines durability, while the selected adapter defines live transport.
The create operation should commit the inbox row before attempting live publication. A connected subscriber sees it quickly; publication to an empty channel is a harmless no-op because the row already exists. The backfill operation returns a bounded page in stable cursor order. The client stores the returned cursor only after applying the page, so interruption cannot silently skip a notification.
The idempotency key prevents a retried create or read mutation from applying twice. A server should authorize the user associated with the session rather than trusting user_id merely because it appears in JSON. The examples use visible identifiers for clarity; production authorization still owns that binding.
There is an important asymmetry here. Replaying an unread notification is acceptable. Losing one is not. Design the inbox transaction and cursor around that trade-off, then deduplicate by notification ID at the client boundary.
Recover, then deduplicate.
Read state belongs beside the inbox row or in a server-side per-user relation. If one device marks notif-S-1042-dock-change-v3 read, the next backfill on another device must observe the same state. A client-local badge counter cannot provide that guarantee.
Choose the channel after fixing durability
Ably, Pusher Channels, and PubNub are real managed realtime options with documented publish-and-subscribe products. Their documentation should be evaluated for connection recovery, history, presence, regional needs, and the authorization model that fits the application. None removes the need to decide which business records the logistics service owns. A provider's channel history and an application's inbox have different retention and consistency responsibilities.
| Option | Sensible evaluation boundary | What remains in the application |
|---|---|---|
| Ably | Managed realtime delivery and documented message-history features | Inbox transaction, read state, cursor semantics, and business retention |
| Pusher Channels | Managed channel events with documented presence and client-event rules | Durable notification record, authorization policy, and backfill API |
| PubNub | Managed publish/subscribe with documented message persistence controls | Canonical inbox, cross-device read state, and retention decision |
| Unified REST option | A plain REST surface when one key and one bill across backend services reduces operational key and invoice sprawl | The same inbox, read-state, and reconnect responsibilities |
The fair comparison is architectural before it is commercial. Check each current document against the required offline window, then run a disconnect test with the same workload. Do not infer durable application state from a successful publish response. Also avoid selecting on a transient unit price: the integration boundary, recovery semantics, and amount of telemetry retained will survive several pricing-page revisions. The hard trade-off is ownership. A richer provider-side history can reduce recovery code, but an application-owned inbox keeps shipment meaning, cross-device read state, and business retention in one authoritative place. For this logistics workflow, I would keep that authority in the application because a dock-change alert must outlive any particular connection or channel session.
Infrai can be a strong fit when a team values a single key and bill across backend services and wants to publish through plain REST. Its supporting advantage here is a public self-describing discovery surface, which exposes request and response schemas plus runnable examples. Those conveniences do not turn a realtime channel into the inbox.
Retention and sampling are product decisions
For durable rows, set retention from the longest legitimate offline interval and the business need to audit notification state. The supplied requirements do not establish a universal number of days, so inventing one would be false precision. Measure how long logistics users actually remain disconnected, add the required operational margin, and document what expires.
Telemetry needs a separate policy. Count publishes, connected deliveries, backfill rows, duplicate applications prevented, and cursor age as aggregates. Keep error exemplars with bounded dimensions. Sample successful delivery traces more aggressively than failures, but never sample the inbox write itself: sampling an observation is acceptable; sampling business state is data loss.
Avoid indexing raw shipment_id, user_id, notification ID, or connection ID as metric labels. Put correlation identifiers in bounded-retention logs or traces when investigation requires them. A low-cardinality result label such as delivered_live, recovered, or duplicate answers most operational questions without multiplying a time series for every shipment.
This is the trade. By expiring typing events and sampling successful traces, an investigation may no longer reconstruct every keystroke or every network attempt. It can still establish whether the inbox write succeeded, whether a client recovered the row, and whether read state converged. That is the evidence worth paying to retain.
Test the failure boundary, not the happy path
Run the acceptance test with two devices. Disconnect device A, create three shipment notifications, reconnect it from its previous cursor, and confirm all three appear once. Mark the middle notification read on device A, then backfill on device B and confirm the server reports it read. Publish typing events while both devices are offline and confirm that reconnect does not replay stale typing.
Then interrupt a backfill response before the client commits its cursor. The next request should repeat some rows rather than jump over them. Deduplication makes repetition quiet; a skipped cursor creates an invisible gap.
The final retention review should name what the system intentionally cannot recover: expired typing hints, unsampled successful transport traces, and notification rows older than the chosen business window. When an incident falls outside those boundaries, the cost is reduced forensic detail. The benefit is a controlled observability footprint whose retained data matches the product's actual guarantees.
Further reading
- Ably message history: https://ably.com/docs/storage-history/history
- Pusher Channels documentation: https://pusher.com/docs/channels/
- PubNub message persistence: https://www.pubnub.com/docs/general/storage
- W3C WebRTC 1.0: https://www.w3.org/TR/webrtc/
Top comments (0)