Real-TimeGENERALNETWORK-SPECIFICSIMPLIFIED

Resynchronisation After a Gap

The disconnect left a hole. Replay from a cursor or refetch a snapshot — but never resume as if nothing was missed, which is what everybody ships first.

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

The connection is back. What happened while it was gone, and how do I get this client to the truth?

The user intent

Someone came out of a lift. The page says "Live" again. They expect the numbers to be right — and, more importantly, they will act as if the numbers are right whether or not they are.

The obvious build

On reconnect, resubscribe and carry on applying events. The connection is healthy again, so the stream is correct again.

Why it breaks

The three events that happened during the outage are simply absent. Nothing in the UI indicates this, because the absence of an event looks exactly like the absence of a change.

How it breaks in a real browser
  • The three events that happened during the outage are simply absent. Nothing in the UI indicates this, because the absence of an event looks exactly like the absence of a change.
  • A row deleted during the gap is still on screen and still clickable. The user clicks it and gets an error from an endpoint that is telling the truth about state the client never learned (Loading, Error, Empty — The States You Did Not Render).
  • Client and server now disagree permanently. Every subsequent event is applied on top of a wrong base, so the divergence never heals — it compounds (State Synchronization).
  • The indicator says "Live", which is the worst part. A page that admits it is stale is safe; a page that is confidently wrong is the failure mode this whole module exists to prevent.
  • Reconnecting from a cursor that the server no longer retains silently starts from "now", so the client believes it resumed while actually having skipped an unknown amount (Server-Sent Events).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A disconnect creates a gap: a window of server-side changes with no corresponding client-side events. The gap exists whether or not anything happened in it, and the client cannot tell those two cases apart without asking.
  • Replay from a cursor closes the gap by asking the server for everything after the last position the client processed. It is precise and incremental, and it depends entirely on the server retaining enough history (Cursor Pagination: An Opaque Bookmark, Not a Position in API Design).
  • Refetch a snapshot closes it by throwing the local state away and asking for current truth, then resuming the stream from the position that snapshot represents. It always works and costs a full payload.
  • Both need a position: an event id, a sequence number or a version the snapshot is consistent with. Without one, the snapshot and the stream cannot be joined without a race (Out-of-Order Responses).
  • Retention is the deciding constraint. Replay is only possible within the window the server keeps, so a client that was gone longer than that window must fall back to a snapshot — meaning both paths have to exist in any client that can be offline for a long time.
  • Deletions are the asymmetry that catches people. A delta replay can carry a delete event, but a snapshot heals deletions only if the client replaces its collection rather than merging into it (The Seven Kinds of State).

What this makes the browser do

And which of it is avoidable.

  • A snapshot refetch is one large response to parse and one large state commit — the parse can be a long task on a slow device at exactly the moment the user is watching (Long Tasks).
  • A replayed backlog is many small events. Applied one at a time it produces one commit and one render each; batched, it is a single commit (The Rendering Opportunity).
  • Replacing a whole collection invalidates every row's identity unless keys are stable, so the browser rebuilds DOM that did not need to change (Reconciliation and Keys).
  • Avoidable work: resynchronising screens the user is not looking at. Resync on becoming visible instead, and the expensive path runs once rather than per tab (content-visibility).

The gap, and what it does to state

The diagram is the whole bug in one picture. Between the disconnect and the reconnect, the server's state moves and the client's does not. The client is not aware of missing anything, because the only signal it had was the events it did not receive. Resuming the stream at this point means every future event is applied to a base that is already wrong.

The divergence below is deliberately small and deliberately mundane. It is not a dramatic corruption — it is one deleted row and one price change, which is exactly what makes it survive review. Nothing looks broken; the numbers are simply not the numbers (Eventual Consistency in Practice in Backend Engineering).

  • The client's sequence number jumped from 41 to 45. That jump is the only evidence a gap occurred, and it is free to check (Ordering and Duplicate Delivery).
  • The deletion is the one a merge-based snapshot will not heal; collections have to be replaced, not merged.
  • Every consequence in the last block is a user action taken on wrong data, which is why the honest state label matters as much as the recovery.
Three events land in a hole
network returnsthe defaultno symptomthe workConnected: events applied in orderConnection dropsGap: server changes, client does notReconnect: transport healthyResume as if nothing was missedResync: replay from cursor, or snapshotPermanently divergent state, labelled "Live"Client matches server; then show "Live"
UserLLMAgentToolDataDecisionHumanGuardrail
Server                                    Client (resumed naively)
──────────────────────────────────────    ──────────────────────────────────────
seq 41  order o_12 -> paid                 seq 41  order o_12 -> paid      ✓
── connection drops here ─────────────────────────────────────────────────────
seq 42  order o_12 -> refunded             (missed)
seq 43  order o_15 deleted                 (missed)
seq 44  order o_18 total 1299 -> 1499      (missed)
── reconnect; subscription resumes at 45 ─────────────────────────────────────
seq 45  order o_21 created                 seq 45  order o_21 created      ✓

Resulting disagreement, indicator still reading "Live":
  o_12   server: refunded    client: paid        <- user refunds it again
  o_15   server: deleted     client: present     <- user clicks it, gets a 404
  o_18   server: 1499        client: 1299        <- user quotes the wrong price

Replay from a cursor, or refetch a snapshot

Both strategies are correct and neither is sufficient alone. Replay is cheap, incremental and precise, and it stops working the moment the gap is older than what the server retains. A snapshot always works and costs a full payload plus a full re-render. A client that can be offline for an unbounded time needs both, plus the rule for switching between them.

Whichever you choose, the join is the part that goes wrong. Events keep arriving while the recovery request is in flight, so the recovery response must carry the position it is consistent with, and the buffered events must be filtered against it. Joining by time instead of by position is the bug that turns a resync into a corruption (Out-of-Order Responses).

The connection is back — now what?

How large is the gap, how much history does the server retain, and how expensive is a full snapshot for this dataset?

Replay from a cursor

when The gap is short, the server retains history covering it, and the dataset is large enough that a snapshot is expensive.

cost Depends on retention you do not control; needs an explicit "cannot replay from there" error, or the client will silently resume from now and believe it caught up.

Refetch a snapshot and resume from its position

when The gap is long or unknown, retention cannot cover it, or the client has just started and has no position at all.

cost A full payload, a full state commit and a full re-render, all at the moment the user is watching for signs of life (List Virtualization).

Replay, falling back to a snapshot

when Any client that can be backgrounded, suspended or offline for an unbounded time — which is every client.

cost Two code paths and a switching rule, both of which need testing against a gap you have to manufacture deliberately.

Invalidate and let the cache layer refetch

when The live stream is an optimisation on top of a query cache that already knows how to fetch what a screen needs.

cost A burst of refetches on reconnect, one per active query key, which is its own small stampede (Query Keys and Invalidation).

Reload the page

when A last-resort escape hatch offered to the user explicitly, never taken automatically.

cost Discards unsaved input, scroll position and focus. Recovery that loses the user's work is not recovery (Form State Is a Draft).

What resynchronising costs the browser

FRAMEWORK-SPECIFICWhether a collection replacement patches in place or rebuilds depends on the reconciliation strategy: a keyed virtual-DOM diff and a fine-grained signal-based binding both preserve nodes but by different mechanisms, while replacing innerHTML destroys everything regardless of framework (Reactivity Models).

Correct data delivered as a frozen page is a poor trade, and it is the trade a naive snapshot apply makes. The pipeline cost of resynchronisation depends almost entirely on whether entity identity survives it: patch a row and the browser restyles and repaints a small area, replace the collection with new objects and every row is a new node with new geometry (The Cost of a Change).

The rows below assume a list of rows with stable keys. The maybe answers are honest — whether a change escapes past style depends on what the change was and what else is on the page, which is exactly why the caveat is there rather than a confident yes (What a Mutation Costs).

Applying a resync to a visible list
ChangestylelayoutpaintcompositeWhy
Patch one field on one row (text content)yesmaybeyesyesStyle recalculation is scoped to the element; layout is only needed if the new text changes the box's intrinsic size, which for a fixed-width numeric column it usually does not.
Replace the whole collection, keys preservedyesmaybemaybeyesWith stable keys the framework patches in place, so the cost approximates the sum of the rows that actually changed rather than the size of the list (Reconciliation and Keys).
Replace the whole collection, identity lostyesyesyesyesEvery node is destroyed and recreated, so every box must be laid out and painted again — and focus and text selection inside the list are lost with the nodes.
Reorder rows after a resyncyesyesmaybeyesGeometry changes for everything after the first moved row; paint may be avoidable if the rows themselves are unchanged and the engine can reuse their painted output.
Remove rows deleted during the gapyesyesyesyesEverything below shifts up, which is a layout the user perceives as content jumping — worth animating or batching so it happens once (Visual Stability).
Apply 200 replayed events one at a timeyesyesyesyesThe stages are not the problem; running them up to 200 times is. Buffering into one commit per frame collapses this to roughly the cost of the final state (Yielding and Scheduling).
Update a status label outside the listyesnoyesyesReserve the label's space so that "Reconnecting" and "Live" occupy the same box; otherwise an honest status indicator becomes a source of layout shift.

caveat The maybe rows depend on facts outside the change itself: whether the new content alters intrinsic size, whether the element is in its own composited layer, whether containment is applied to the list, and how the framework keys its children. Measure the specific case in the Performance panel rather than trusting the table (CSS Containment).

How to build it

Most important first.

  • Track a position on every applied event and persist it if the session can survive a reload. The position is what makes both recovery strategies possible; without it neither is (Persistent Client State).
  • Choose by gap size and retention: short gap and history available, replay; long gap, unknown gap, or a cursor the server cannot honour, snapshot. Implement the snapshot path first — it is the one that always works.
  • Join the snapshot to the stream by position, not by time. Buffer inbound events while the snapshot is in flight, then apply the snapshot and drain the buffer, dropping anything at or before the snapshot's position (Five Components, One Request).
  • Replace collections rather than merging them when applying a snapshot, so entities deleted during the gap actually disappear.
  • Do not show "Live" until resynchronisation has completed. "Reconnecting", then "Catching up", then "Live" is three honest states and costs almost nothing (Loading, Error, Empty — The States You Did Not Render).
  • Make resync idempotent too — it will be triggered twice, by a reconnect and by a gap detection, and the second run must be harmless (Ordering and Duplicate Delivery).

Keyboard, focus, semantics, announcement

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

  • A catching-up state must be perceivable, not just visible. A polite status region moving through "Reconnecting", "Catching up", "Live" tells a screen-reader user what a spinner tells everyone else (Live Regions and Announcement).
  • A snapshot that replaces a list destroys and rebuilds DOM, which drops focus and loses a screen reader's position. Preserve identity by key and restore focus to the equivalent element afterwards (Focus Management).
  • Do not announce the individual events of a replayed backlog. One summary — "12 items updated while you were offline" — is informative; twelve announcements are not.
  • If content the user was reading was deleted during the gap, removing it silently leaves them somewhere that no longer exists. Say so, and move focus deliberately rather than letting it fall to the document (Accessible Component Patterns).
  • Stale content must never be presented as current. For a screen-reader user there is no visual cue that the numbers stopped moving, so the staleness has to be in the text (Semantics Before ARIA).

What can go wrong

Failure modes
  • Resuming as if nothing was missed. The default behaviour of every naive client and the reason this lesson exists.
  • Requesting replay from a cursor beyond the retention window and receiving a silent start-from-now instead of an explicit "cannot replay" error (The Error Model: Structure Over Apology in API Design).
  • Snapshot merged rather than replaced, so entities deleted during the gap remain on screen forever — the longest-lived class of bug in this module.
  • The snapshot and the buffered events applied in the wrong order, so the snapshot overwrites newer events that arrived while it was in flight.
  • The mitigation failing: resyncing on every reconnect including sub-second blips, so a flaky network produces a full snapshot refetch every few seconds and the resync becomes the load problem.
  • Resync racing itself — two triggers, two in-flight snapshots, and whichever resolves last wins regardless of which is newer (Cancelling a Request Nobody Is Waiting For).
What can arrive out of order
  • The snapshot in flight while events continue to arrive. Applying the snapshot last overwrites newer events; applying it first without buffering loses them. Position comparison is the only correct join (Out-of-Order Responses).
  • Two resyncs triggered at once — one by reconnect, one by gap detection — with the slower one landing last and reinstating older state (Cancelling a Request Nobody Is Waiting For).
  • A replay stream racing the live stream after reconnect, delivering overlapping windows that must be deduplicated by event id (Ordering and Duplicate Delivery).
  • A local optimistic mutation made during the gap racing the snapshot that does not contain it, so the user's own change disappears and then reappears (Optimistic UI).
Security
  • A replay request names a position, and a position is a claim by the client. The server must confirm this client is still permitted to see everything in that range — permissions may have been revoked during the gap (Where Authorization Must Live in Security).
  • A snapshot after reconnect is exactly the right moment to re-evaluate authorization: it is a fresh request, with fresh credentials, and it can legitimately return less than it did before (Authorization-Aware UI).
  • Do not trust a client-supplied cursor to bound how much history it can read. A cursor from far enough back is a request to read a large amount of data, and unbounded replay is a resource-exhaustion vector (Rate Limiting in Backend Engineering).
  • Resync is often the first request after a session expired mid-gap. It must handle a 401 by sending the user to sign in rather than retrying, otherwise a lapsed session becomes a retry loop (Session Expiry and the Refresh Race).
Misreads
  • "Reconnected means synchronised." Reconnected means the transport works. Synchronised means the gap has been closed, and nothing does that automatically (Reconnect and Backoff).
  • "Last-Event-ID handles it." Only if the server implements replay from that id and retains enough history. The browser sends the header; the server decides whether it means anything (Server-Sent Events).
  • "Merging a snapshot is safer than replacing." Merging cannot express deletion. It is the reason rows deleted during an outage live on for the rest of the session (The Seven Kinds of State).
  • "We can just reload the page on reconnect." That discards unsaved input, scroll position and focus — a data-loss bug dressed as a recovery strategy (Form State Is a Draft).
  • "The gap is rare." On mobile it is several times an hour, and its consequence — quietly wrong data presented as live — is the highest-severity outcome in this module.

Measuring it, and what changes in the field

How you would see this
  • Count resynchronisations, and break them down by trigger: reconnect, detected gap, manual. A rising rate is a networking problem showing up in the wrong place (Frontend Error Tracking).
  • Measure time from reconnect to "Live" — the interval during which the user believes they have current data and does not (Interaction Responsiveness).
  • Count replays that fell back to a snapshot because retention could not cover the gap. That ratio is what tells the backend team whether the retention window is right (Real User Monitoring).
  • Diff client state against a fresh snapshot in a canary or a development build. Silent divergence has no symptom, so the only way to find it is to look for it (Debugging State).
Slow device, slow network, large data, old tab
  • On a mobile network, gaps are frequent and short, which favours cursor replay with a debounce so that sub-second blips do not trigger a full resync.
  • On a laptop reopened the next morning, the gap is enormous and no reasonable retention covers it, so the snapshot path is the only path (Long-Lived Clients and Version Skew).
  • With a large dataset, a snapshot is expensive enough that replay is worth real effort — and paginated snapshots introduce their own consistency question, since the pages are read at different moments (Pagination From the Interface Backwards).
  • On a slow device, applying a large snapshot is a rendering problem: the correct data arriving as a frozen page is not obviously better than stale data (The Frame Budget).
What this costs
  • Replay is cheap and precise and depends on server retention you do not control; snapshot always works and costs a full payload plus a full re-render. Serious clients implement both and choose at runtime.
  • Buffering events during a snapshot fetch adds a queue, a drain step and a position comparison — perhaps thirty lines, all of which must be right or the resync makes state worse than the gap did.
  • Three honest connection states cost design and copy work, and they are what stops a user acting on stale data. That trade is not close.
  • Debouncing resync to avoid thrashing on a flaky network means a real gap is sometimes closed a little later than it could have been.

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.

  • GENERALThe gap and the two strategies for closing it apply to every push transport and to offline mutation queues as well; nothing here depends on a browser, an engine or a framework.
  • NETWORK-SPECIFICGap frequency and gap length are set by the user's network: a commuter on a train produces many short gaps favouring cursor replay, while a laptop resumed the next morning produces one enormous gap that only a snapshot can close.
  • SIMPLIFIEDThis treats the server as the single source of truth with a linear event log; systems with offline writes, multi-master replication or CRDT merge semantics have a genuine conflict-resolution problem here rather than a catch-up problem (The Offline Mutation Queue).

Where the depth lives

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

Domains that do not exist yet
  • Distributed Systems — retention windows, log compaction and why "replay from where I was" is a question the server can legitimately refuse to answer.
  • Software Design — modelling a client-side cache as a projection of an event log, and what that buys over a bag of entities updated in place.