Real-TimeGENERALSIMPLIFIEDNETWORK-SPECIFIC

Ordering and Duplicate Delivery

At-least-once is the normal case: events arrive twice, arrive late, and arrive in an order nobody promised. The UI's job is to be idempotent per event.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

If the same event can arrive twice and two events can arrive backwards, what does my reducer have to look like?

The user intent

Someone is watching a list of orders. They expect it to match what a colleague sees on the next desk, and they expect an order that was cancelled to stay cancelled.

The obvious build

Each message describes something that happened, so append it or apply it in the order it arrives. The server sends events once, in order, so the arrival order is the truth.

Why it breaks

The same event arrives twice after a reconnect, because the server replays from the last position it was told about rather than the last one the client actually processed. A naive append shows the order twice (Resynchronisation After a Gap).

How it breaks in a real browser
  • The same event arrives twice after a reconnect, because the server replays from the last position it was told about rather than the last one the client actually processed. A naive append shows the order twice (Resynchronisation After a Gap).
  • An order.updated arrives before the order.created it updates, because they were produced by two services and travelled two paths. The reducer creates a ghost row from an update to a row that does not exist yet.
  • A stale update arrives after a newer one — an out-of-order delivery — and the UI rolls backwards to a status the entity no longer has. This is the bug users describe as "it flickered and then it was wrong".
  • A counter incremented per event double-counts on every duplicate, and unlike a wrong list, nothing about it looks wrong. It is simply the wrong number, forever.
  • The optimistic local change and the server's echo of the same change both apply, so the user sees their own edit twice (Optimistic UI).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • At-least-once is the delivery guarantee almost every real system provides. The producer retries when it does not receive an acknowledgement, and a lost acknowledgement is indistinguishable from a lost message — so the safe choice is to send again (At-Least-Once Delivery in Backend Engineering).
  • Exactly-once delivery is not available. What is available is at-least-once delivery plus idempotent processing, which produces exactly-once *effect* — and the second half of that sentence is the client's responsibility (Idempotency vs Deduplication in API Design).
  • Ordering is guaranteed only within whatever the system defines as a stream: one connection, one partition, one entity's log. Between two such units there is no ordering relationship at all (Kafka-Style Logs: Topics, Partitions, Offsets in Architecture).
  • A sequence number monotonic within a stream lets a client detect a gap — "I have 41, this is 43" — which is a different and more useful signal than a timestamp, because clocks on different machines disagree (Sequence Numbers, ACKs and Reassembly in Networking).
  • An event id lets a client detect a repeat. A version per entity lets a client reject a stale update. They answer different questions and most systems need both.
  • Wall-clock timestamps are the wrong tool for ordering. They come from machines whose clocks drift, and comparing two of them tells you about the clocks as much as about the events (Ordering Guarantees: Four Levels, Four Prices in Concurrency).

What this makes the browser do

And which of it is avoidable.

  • Deduplication needs memory. A set of seen ids grows without bound unless it is capped, and an unbounded set in a tab that stays open for a working day is a leak with a business justification attached (Memory Leaks).
  • A version compare per event is a cheap object lookup; a full re-sort of a large list per event is not. Where the ordering work lands decides whether high message rates are affordable (List Virtualization).
  • Applying events one at a time drives one state commit and one render each. Buffering a burst into a single commit is the difference between a smooth reconnect and a frozen one (The Rendering Opportunity).
  • Avoidable work: re-deriving sorted, filtered and grouped views from scratch on every event when only one entity changed (Derived State).

Where the duplicate comes from

Duplicates are not a defect somebody will eventually fix. They are the direct consequence of a design decision made everywhere in the path: when an acknowledgement does not come back, send it again. That is the correct choice, because the alternative — assume it arrived — loses messages, and losing messages is worse than repeating them.

Each step below can independently produce a repeat, and they compose. The client is the last stop in the chain and therefore the only place that can make the repetition harmless, which is why "the UI must be idempotent per event" is a structural requirement rather than defensive programming (Event-Driven Backends in Backend Engineering).

Every hop that can repeat itself
  1. 1
    Producer writes the event

    A service records that something happened and hands it to a broker.

    fails by The write succeeds and the acknowledgement is lost, so the producer writes it again — two events, one occurrence.

  2. 2
    Broker stores and fans out

    Persists the event and delivers it to subscribers, retrying on failure.

    fails by Redelivery after an unacknowledged attempt. This is at-least-once by design, not by accident (Queue Semantics in Backend Engineering).

  3. 3
    Subscription server pushes to the connection

    Writes the event onto the socket or stream for this client.

    fails by The connection dies mid-write; on reconnect the server replays from the last position it recorded, which is behind what the client actually processed.

  4. 4
    Transport delivers to the browser

    Frames or event blocks arrive and are parsed.

    fails by Two connections briefly overlap during a reconnect and both deliver the same window.

  5. 5
    Client dispatches to a handler

    The message becomes a call into application code.

    fails by Two subscriptions to the same stream in one page, so every handler runs twice.

  6. 6
    Reducer applies to state

    State changes; the UI re-renders.

    fails by This is the only step you fully control, and the only one where a repeat can be made to cost nothing.

Five of the six steps are outside the browser. The sixth is where the problem is solved.

A reducer that does not care how many times it runs

Idempotence here is not an abstract property, it is a shape: key by entity id, merge rather than append, and refuse anything that is not strictly newer than what you already hold. A reducer with that shape can be handed the same event a hundred times, or handed events backwards, and produce the same answer.

The gap check is the part that is usually missing. Deduplication and version comparison make a duplicate or a reordering harmless, but neither can invent an event you never received. Detecting the gap is what turns a silent divergence into a resynchronisation (Resynchronisation After a Gap).

  • A dedupe set answers "is this a repeat"; a per-entity version answers "is this stale". Neither substitutes for the other.
  • Merging by id makes create-then-update and update-then-create converge to the same state, which removes the ghost-row problem entirely.
  • Returning early on an unknown type is what lets an old tab survive a deploy rather than throwing on the first new message (Backward Compatibility: The Real Rules in API Design).
Idempotent per event, and honest about gaps
1type Event = { id: string; seq: number; entityId: string; version: number; type: string; patch: Record<string, unknown> }
2
3let lastSeq = 0
4const seen = new Set<string>() // bounded below
5const byId = new Map<string, { version: number; data: Record<string, unknown> }>()
6
7function applyEvent(e: Event) {
8 // 1. Repeat? Applying twice must equal applying once.
9 if (seen.has(e.id)) return
10 seen.add(e.id)
11 if (seen.size > 5000) trimOldest(seen, 1000) // a leak with a good excuse is still a leak
12
13 // 2. Gap? A jump means something was missed; guessing is how state diverges.
14 if (lastSeq !== 0 && e.seq > lastSeq + 1) requestResync({ from: lastSeq })
15 lastSeq = Math.max(lastSeq, e.seq)
16
17 // 3. Stale? An older version arriving late must not overwrite a newer one.
18 const current = byId.get(e.entityId)
19 if (current && e.version <= current.version) return
20
21 // 4. Unknown shape? A tab open across a deploy will see types it predates.
22 if (!KNOWN_TYPES.has(e.type)) return
23
24 byId.set(e.entityId, { version: e.version, data: { ...current?.data, ...e.patch } })
25 scheduleCommit() // one commit per frame, not one per event
26}

Four guards, in order, each answering a different question: have I seen this exact event, did I miss one, is this older than what I hold, and do I understand it at all. scheduleCommit is the fifth: buffering into one state change per frame is what keeps a replayed backlog from freezing the page (Yielding and Scheduling).

What a sequence number can and cannot tell you

SIMPLIFIEDThis assumes the server sends an entity-shaped patch with a version; a delta-only protocol where each event describes a change relative to the previous one cannot be made idempotent this way and needs either full replay from a cursor or a CRDT-style merge, which is a much larger commitment.

Sequence numbers are frequently oversold. Within one stream, a monotonic counter is an excellent gap detector and a decent ordering key. Across streams it means nothing — two events with sequence numbers 8 and 9 from different partitions carry no information about which happened first (Kafka-Style Logs: Topics, Partitions, Offsets in Architecture).

The comparison below is the two-line version of this entire lesson, and the because is the part worth remembering: the second form is not tidier, it is a different function. It has the property that its output does not depend on how many times or in what order it was called.

The same feature, two delivery assumptions
Assumes exactly-once, in order
socket.onmessage = (m) => {
  const e = JSON.parse(m.data)
  orders.push(e.order)        // duplicate -> two rows
  unreadCount += 1            // duplicate -> permanently wrong number
  setOrders([...orders])      // one render per message
}
Assumes at-least-once, unordered
socket.onmessage = (m) => {
  const e = JSON.parse(m.data)
  if (seen.has(e.id)) return                 // repeat: no-op
  seen.add(e.id)
  const cur = byId.get(e.order.id)
  if (cur && e.version <= cur.version) return // stale: no-op
  byId.set(e.order.id, { ...cur, ...e.order, version: e.version })
  unreadCount = countUnread(byId)             // derived, not incremented
  scheduleCommit()                            // one commit per frame
}

The second version is a pure function of the set of events received, rather than of the sequence in which they happened to arrive. That is what makes duplicates and reorderings — which the network will produce regardless of what anyone intended — cost nothing instead of corrupting state. Deriving the counter rather than incrementing it removes the failure mode that has no visible symptom.

How to build it

Most important first.

  • Make every handler idempotent. Applying the same event twice must produce the same state as applying it once — this is the single design rule that makes the whole category of bugs disappear (Idempotency in API Design).
  • Key state by entity id and apply updates as a merge, not an append. byId[event.id] = merge(byId[event.id], event) is idempotent almost by construction; list.push(event) is not (Node Identity Across Updates).
  • Carry a version or a sequence number per entity and drop any event whose version is not newer than what you hold. This turns out-of-order delivery from a correctness problem into a no-op.
  • Keep a bounded set of recently seen event ids for events that are not entity updates — counters, notifications, anything whose effect is not naturally idempotent.
  • Detect gaps rather than assuming continuity. If sequence numbers jump, you missed something, and the correct response is to resynchronise rather than to carry on (Resynchronisation After a Gap).
  • Ignore unknown event types instead of throwing. A client that has been open across a deploy will receive message shapes it has never seen, and one exception in a handler can take down every message after it (Long-Lived Clients and Version Skew).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A list that reorders as events arrive moves content under a screen-reader or keyboard user mid-read. Where ordering is not essential, apply updates in place and defer reordering to an explicit user action or a natural pause (Keyboard Operability).
  • A duplicated event that reaches a live region announces twice, which reads as two things happening. Idempotence is therefore an accessibility property, not only a data-integrity one (Live Regions and Announcement).
  • When a burst of replayed events lands, announce the summary — "5 orders updated" — rather than each event. Announcing a backlog individually is unusable.
  • Preserve focus across re-renders driven by events. If a row is re-created rather than updated, focus inside it is lost, and a keyboard user is returned to the top of the document without explanation (Focus Management).
  • Never let an inbound event steal focus or move the caret. Someone typing when an event arrives must keep both their position and their text (Form State Is a Draft).

What can go wrong

Failure modes
  • Append-only reducers: every duplicate becomes a visible row, and the list is wrong in a way the user can see and you cannot reproduce.
  • Increment-based counters: every duplicate is silently absorbed into a number that never gets corrected.
  • Last-write-wins by arrival order: a delayed old event overwrites a newer one, and the UI shows a state the server abandoned minutes ago.
  • Ghost entities created by an update for something that has not arrived yet, then never cleaned up because the create was processed as a no-op merge.
  • An unbounded dedupe set that quietly grows for the life of the tab.
  • The mitigation failing: deduplicating on a hash of the payload rather than an event id, so two legitimately identical events — the same value set twice — are collapsed into one and a real change is lost.
What can arrive out of order
  • The snapshot and the stream: a fetched snapshot resolving after events 41 and 42 have been applied will roll the UI back unless the snapshot carries a position and is discarded when it is older (Out-of-Order Responses).
  • Two events for the same entity from two producers, arriving in the opposite order to the one in which they were produced.
  • The local optimistic write and the server echo of the same change, racing in both directions — the echo can arrive before the request that caused it completes (Optimistic UI).
  • Two connections briefly alive during a reconnect, delivering the same window of events twice with different arrival interleavings in each (WebSockets in the UI).
Security
  • A duplicate is indistinguishable from a replay. If any inbound event has a side effect beyond rendering, idempotency is also a replay defence and the server must enforce its half (Replay Attacks in Security).
  • Never let an event id or version supplied by a message decide an authorization outcome. The client can be sent anything; permission is a server decision, per action, every time (Where Authorization Must Live in Security).
  • Validate the shape of every event before it reaches state. A pushed message is the least-reviewed input path in most applications and lands in the DOM just like any other (Cross-Site Scripting).
  • Event ids and sequence numbers are metadata about the stream and often leak volume information — a monotonic global id tells any client roughly how much traffic the whole system handles.
Misreads
  • "The socket guarantees ordering, so I do not need sequence numbers." It guarantees ordering within one connection. Every reconnect ends that guarantee, and reconnects are the normal case (Reconnect and Backoff).
  • "Exactly-once delivery is a configuration option." It is not achievable end to end. At-least-once delivery plus idempotent processing is the achievable thing, and the second half runs in your reducer (At-Least-Once Delivery in Backend Engineering).
  • "Timestamps order events." They order clocks. Two producers with a small drift will produce timestamps that disagree with causality (Ordering Guarantees: Four Levels, Four Prices in Concurrency).
  • "Duplicates are a server bug." They are the expected consequence of retrying without an acknowledgement. A system that never duplicates is a system that sometimes loses messages instead.
  • "If I dedupe by id, I am done." Deduplication handles repeats; it does nothing about an old event arriving after a new one, which needs a version comparison instead.

Measuring it, and what changes in the field

How you would see this
  • Count duplicates and out-of-order events explicitly and report them. A client that silently absorbs both never tells you the rate is climbing (Frontend Error Tracking).
  • Count detected sequence gaps. A gap is the only reliable signal that a resynchronisation was needed, and it is invisible unless you look for it.
  • The frame list in the Network panel shows arrival order directly, which is the fastest way to confirm that a reordering bug is delivery and not your reducer (Debugging the Network).
  • A state-diff log during development — what changed, from which event — turns "it flickered" into a specific event id (Debugging State).
Slow device, slow network, large data, old tab
  • On a flaky network, reconnects are frequent, so replayed duplicates are frequent. The duplicate rate is a function of the user's network rather than of your system (Reconnect and Backoff).
  • With high fan-out across several producing services, cross-entity ordering violations become routine rather than rare, because the events genuinely travelled different paths.
  • With a large dataset, the cost of the version check is nothing and the cost of the re-sort is everything, so where you put the ordering work determines the message rate you can sustain.
  • In a long-lived tab across a deploy, unknown event types arrive. A strict reducer that throws on unknown input fails permanently, while a tolerant one degrades (Deploying a Frontend).
What this costs
  • Idempotent merge-by-id reducers cost more code than appending and require every event to carry an id and a version — a contract obligation on the API, negotiated before the UI is written (How API Shape Drives UI Complexity).
  • A dedupe set costs memory proportional to the window you keep, and the window is a guess about the worst replay you will ever see.
  • Dropping stale events by version means a legitimately concurrent edit can be silently discarded; where that matters, you need conflict resolution rather than a version comparison (Rollback and Reconciliation).
  • Detecting gaps and resynchronising is more correct and more expensive than assuming continuity, and it makes the client's behaviour harder to predict from a log (Resynchronisation After a Gap).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALAt-least-once delivery, per-stream ordering and clock unreliability are properties of networked messaging rather than of any browser; the same reducer discipline applies to a WebSocket, an SSE stream, a webhook consumer and a background sync.
  • SIMPLIFIEDThe model here assumes each event names one entity and carries a version; systems built on event sourcing or CRDTs replace the version comparison with a merge function whose result depends on both operands, which is more capable and considerably harder to reason about.
  • NETWORK-SPECIFICThe rate of duplicates and reorderings is set by the user's network and the number of reconnects it causes, so a bug that is invisible on office wifi is reproducible several times an hour on a commuter train.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Distributed Systems — why exactly-once delivery is not available end to end, what a logical clock buys over a wall clock, and how a total order is established when it is genuinely needed.
OS & Networkingsequence-numbers