Realtime message order and delivery guarantees are best explained as separate contracts: arrival proves that a notification reached a consumer, not that it is the newest fact. For patient-facing updates, put a monotonic sequence number on each stream, make consumers idempotent, and define how they recover a gap. The deciding constraint is the guarantee at fan-out: every path that can deliver an update must preserve or reconstruct the same application-level order.
TL;DR: A sender can create A before B while a client observes B before A because creation, transmission, retry, reconnection, and rendering are separate stages. An ordered channel can preserve order within that channel. It cannot impose one global order across multiple channels, sessions, workers, or asynchronous UI work. Carry order in the data.
Replace the timeline in your head
The tempting mental model is a straight line: a producer creates A, then B; the network delivers A, then B; the screen renders A, then B.
That line hides every boundary that matters. A better diagram-in-words is: a care workflow commits an event; a fan-out worker reads it; one connection carries it; the browser validates it; state changes; the screen paints. Add a reconnect path or a second connection and you now have parallel lanes that can finish in a different order.
Picture a medication workflow. Event 41 says a refill request is received. Event 42 says a clinician approved it. If event 42 takes a direct live path while event 41 returns through recovery, arrival order becomes 42, 41. Applying both blindly can move the interface backward from "approved" to "received." Now add a second browser tab that reconnects from an older cursor. It may see 41, then 42, while the first tab sees 42 before its recovery response includes 41. Both tabs received authentic events, and neither packet needs to be corrupt. Yet a reducer that trusts arrival order can leave the tabs showing different states. The payloads are individually valid. The transition is wrong, and inspecting only the final two network frames will not reveal which application transition should win. The sequence assigned when the workflow commits is the missing evidence.
That is the trap.
This distinction is small but decisive:
- Message order is the producer's chosen sequence within a defined stream.
- Delivery order is the sequence in which a particular consumer observes messages.
- Display order is the sequence in which completed application work becomes visible.
Three clocks. One UI.
WebRTC data channels make the boundary concrete. The standard exposes an ordered setting for a data channel, and an application may also configure limited retransmission behavior. Those are channel properties. They do not merge causal history from two data channels or turn a replacement session into the continuation of an old one.
How can realtime message order differ from delivery guarantees?
Because "sent first" describes one point in a distributed workflow, while "delivered first" describes another. Between them, work may proceed concurrently. A larger payload can take longer to process. A retry can race a later successful send. A reconnect can restore an older buffered event after a newer event has arrived on the fresh path. Even after ordered receipt, one handler may await storage while another completes quickly.
The useful question is not, "Does the transport preserve order?" Ask, "Order of what, scoped to which stream, across which lifetime?"
For a healthtech notification feed, a practical stream key might be a patient-visible workflow rather than an entire account. That choice is a trade-off. One global sequence makes comparison easy but couples unrelated activity and creates a broad contention point. Per-workflow sequences isolate traffic and failures, but the client cannot infer a total order between two workflows. Usually it should not try.
Do not confuse ordering with reliability. Ordered delivery says how accepted messages are exposed relative to one another under the channel's rules. It does not, by itself, prove that every business event was accepted, that a disconnected client received history, or that an update was rendered once. Those are separate contracts. Write them down separately.
Put the guarantee in the envelope
Here is a compact TypeScript shape for one workflow stream. It makes the ordering contract visible without tying the application to a transport.
type NotificationEvent = {
streamId: string;
sequence: number;
eventId: string;
kind: "refill.received" | "refill.approved";
occurredAt: string;
};
type StreamState = {
lastApplied: number;
seenEventIds: Set<string>;
};
type ApplyResult =
| { status: "applied"; next: StreamState }
| { status: "duplicate"; next: StreamState }
| { status: "gap"; expected: number; received: number; next: StreamState };
function acceptEvent(state: StreamState, event: NotificationEvent): ApplyResult {
if (state.seenEventIds.has(event.eventId)) {
return { status: "duplicate", next: state };
}
const expected = state.lastApplied + 1;
if (event.sequence !== expected) {
return { status: "gap", expected, received: event.sequence, next: state };
}
const seenEventIds = new Set(state.seenEventIds);
seenEventIds.add(event.eventId);
return {
status: "applied",
next: { lastApplied: event.sequence, seenEventIds },
};
}
The plus sign is not decoration: the next acceptable event is exactly lastApplied + 1. A lower sequence is stale or duplicated. A higher sequence exposes a gap. Do not silently apply the higher event and hope the missing one never matters. Pause that stream, fetch a trusted snapshot or the missing range, then resume from a known sequence.
There is an important implementation detail here. eventId answers "Have I processed this event?" while sequence answers "May I process it now?" One field cannot safely stand in for both jobs. A retried event needs the same identifier and sequence as its original attempt; minting either value again turns a retry into a new business fact.
The code deliberately does not buffer out-of-order events. Buffering is a policy, not a free guarantee. A bounded buffer can smooth a short race, but it also consumes memory and introduces a timeout decision. For patient notifications, recovering from an authoritative state after a gap is often easier to reason about than holding an unbounded queue in a browser tab.
Test the transition, not the happy-path packet
A copy-pasteable reducer is useful only if the team attacks its boundaries. Start with event 42 before 41. Send event 41 twice. Disconnect after receipt but before durable local state changes. Replace the connection, then deliver one event from the old path. Run two handlers where the earlier event awaits slower work.
The assertions should describe visible invariants: the state never moves backward; one eventId changes state at most once; a gap triggers recovery; recovery establishes a new lastApplied; events from the wrong streamId never advance this stream. These tests are deterministic. No timing luck required.
Observability should use the same vocabulary. Record streamId, sequence, eventId, the last applied sequence, and the result category: applied, duplicate, or gap. Count gaps and duplicates separately. A duplicate can be harmless evidence of retry behavior; a gap means the consumer cannot yet justify the next transition.
Be careful with labels. A raw patient identifier or notification body does not belong in broadly accessible telemetry. Use a non-sensitive correlation value appropriate to your data governance rules, and keep payload content out of metric dimensions. The ordering investigation needs sequence metadata, not clinical text.
Deploy the envelope before enforcing it. First, emit sequence metadata and observe it while older clients continue their existing behavior. Next, teach clients to deduplicate. Then enable gap recovery and rejection of stale transitions. This staged path matters because a producer-only change cannot protect consumers that ignore the contract.
Does an ordered channel solve it already?
It solves a narrower and still valuable problem: ordered delivery within the scope promised by that channel. If one producer, one ordered data channel, and one synchronous consumer define the whole stream lifetime, transport ordering removes a class of reordering. Keep it enabled when that matches the application.
But healthtech notification fan-out rarely ends at the socket boundary. Reconnection creates another lifetime. Multiple tabs create multiple consumers. Background and foreground paths may overlap. Application handlers can complete asynchronously. A sequence-bearing envelope remains the audit-friendly statement of which transition is current. Transport order is a useful mechanism; application order is the contract.
The second objection is latency: should a client really wait when event 42 is already available? Sometimes no. If events are independent, place them in different streams and apply each immediately. If 42 semantically depends on 41, showing it early may be fast but false. Define that dependency in the model instead of burying it in arrival timing.
Use this decision rule: choose the smallest stream whose events must be observed in causal order, assign sequence numbers at the authority that commits those events, and make every fan-out path carry those numbers unchanged. Then test retries, gaps, reconnects, and concurrent handlers as first-class cases. The result is plain to explain and possible to operate.
References
- W3C, WebRTC 1.0: Real-Time Communication Between Browsers: https://www.w3.org/TR/webrtc/
Top comments (0)