LESSON 12.3
Delivery and ordering
Durable history, reconnect cursors, duplicate delivery, per-conversation order, and notifying users whose devices are offline.
6 min read
A WebSocket delivers bytes in order while one connection is alive. It doesn't tell you what happens when a phone switches networks, an acknowledgement disappears, or a connection server restarts. Reliable real-time delivery starts with durable data and a way to ask what was missed.
Store before pushing
Give each message a stable id and commit it to the message store before acknowledging the send. Publish committed events through an outbox or change stream so a crash between the database write and the publish doesn't leave a message invisible to live recipients forever.
Accept a client-generated idempotency key for sending too. A retry after a lost acknowledgement returns the original message rather than creating a new one.
Resume from the last seen id
The client saves the last contiguous, applied sequence for each conversation or subscription. On reconnect it asks for events after that cursor. SSE carries event ids and supports Last-Event-ID; a WebSocket protocol needs an explicit resume message. Either way, the server must implement the history lookup and check authorization again.
- The client has applied messages through sequence 104 and loses its connection.
- Messages 105 to 108 are committed while it's away.
- It reconnects with cursor 104 and receives the missing messages in order.
- The server joins replay to live delivery without a gap, using a durable stream position or subscribing first and buffering events while replay runs.
Simply fetching history and then subscribing leaves a race: an event committed between those steps can be missed. Buffered events may overlap the replay, so deduplicate them. Bound the buffer, paginate long catch-ups, and return an explicit resync response when a cursor is older than retention.
At least once, with dedupe
A server sends message 108, the client applies it, and the acknowledgement gets lost. Retrying is correct, but it sends 108 again. The useful contract is at-least-once delivery within the replay window, plus idempotent application on the client.
Track stable message ids or per-conversation sequences. Applying the same message twice mustn't append a second bubble, play the same animation twice, or increment an unread count twice. Persist the applied cursor with local state where practical; after losing local state, rebuild from the server.
Order only what needs ordering
Chat usually needs order within each conversation, not across every conversation in the world. Route commands for a conversation to one owner or log partition and assign an increasing sequence at the durable write. Deliver in that sequence; retries retain their original id and position.
Wall-clock timestamps aren't sufficient: clocks differ and simultaneous messages can share a timestamp. Different conversations run in parallel. A huge room can become a hot partition, so call out that trade-off rather than promising unlimited parallelism and strict order together.
Offline users
When no active session acknowledges delivery after a short grace period, enqueue a push notification through a mobile provider. Presence is only a hint, and push can be delayed or dropped. Opening the app must still fetch messages from durable history.
Keep sensitive bodies out of lock-screen notifications unless settings allow them. Collapse repeated notifications and check read state before sending. Push says "there's something to fetch"; it isn't the source of truth.
Try it
Notification Service HLD
Combine live delivery, offline push and idempotent retries without treating notifications as durable history.