StateGENERALFRAMEWORK-SPECIFICSIMPLIFIED

State Synchronization

The server, the client cache, component state and the URL can all hold a version of the same fact. When they disagree, the only question that matters is which one is authoritative.

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

Four places hold a version of this value and they disagree — which one is right, and how did they diverge?

The user intent

A person changes something and expects everything on screen that mentions it to change too: the row in the table, the count in the header, the detail panel, the badge in the sidebar, and the same view in their other tab.

The obvious build

Update the value everywhere it appears when the change happens. Set it in the component, set it in the cache, and let the next fetch confirm it. If something looks stale, refetch.

Why it breaks

A mutation succeeds, the cache is updated by hand, and one of the four places that render the value was reading a different copy. The table row updates and the sidebar badge does not.

How it breaks in a real browser
  • A mutation succeeds, the cache is updated by hand, and one of the four places that render the value was reading a different copy. The table row updates and the sidebar badge does not.
  • A background revalidation lands while the user is mid-edit and replaces the object the form was bound to, deleting several seconds of typing (Form State Is a Draft).
  • Two requests for the same list are in flight; the slower one was issued first, resolves last, and overwrites newer data with older data (Out-of-Order Responses).
  • The URL says page=3 and the component state says page 1 because a filter change reset one and not the other; the fetch uses one and the pagination control renders the other (The URL Is Application State).
  • An optimistic update is applied, the request fails, and the rollback restores a value that a *different* successful mutation has since changed (Rollback and Reconciliation).
  • Two tabs hold different caches of the same list. The user completes an order in one and works it again in the other, because nothing told the second tab (Auth Across Tabs).
  • "If something looks stale, refetch" becomes the fix for everything, so the application fetches constantly and is still occasionally wrong.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Divergence is not a bug in any one layer; it is the expected outcome of having four layers with independent update paths and no stated authority.
  • The server is authoritative for anything the server owns. It changes without telling you, because other users and other systems write to it (Server State Is Not Your State).
  • The client cache holds a copy that was true at response time, plus a freshness policy. Its correctness is entirely a function of when it revalidates and what invalidates it (The Client Cache Model).
  • Component state holds either a derivation of the cache — which is safe — or a copy of it, which is the divergence. A copy made at mount time never hears about the update (Derived State).
  • The URL holds the parameters that identify what is being viewed. If the fetch key is derived from it, they cannot disagree; if it is mirrored into a store, they will (The URL Is Application State).
  • The four cannot be kept equal. What you can do is arrange them in a direction: server → cache → derivation → render, with the URL as the input that selects what to fetch. Divergence is then either a freshness question or a bug in a single arrow.
  • Every write is a temporary, intentional divergence: an optimistic update says "assume the server will agree" and carries a rollback for when it does not (Optimistic UI).
  • Two tabs are two independent caches of the same server. That is replication, and it needs either a broadcast channel between them or a revalidation on focus — neither of which is free (Resynchronisation After a Gap).

What this makes the browser do

And which of it is avoidable.

  • Every revalidation is a request, a parse and a re-render of everything subscribed to that key. A too-aggressive freshness policy converts a correctness problem into a bandwidth and main-thread problem (Five Components, One Request).
  • Revalidate-on-focus means a burst of requests each time the user returns to the tab, which on a page with many keys can be dozens at once (The Life of a Fetch).
  • An optimistic update costs one render immediately and, on failure, a second render for the rollback plus whatever the error surface costs.
  • Cross-tab messaging via BroadcastChannel or the storage event costs a wake-up and a re-render in every open tab — cheap per tab, and multiplied by however many the user left open (Long-Lived Clients and Version Skew).
  • Copying server data into component state costs memory that is never released while the component lives, and it is memory holding a value that is already wrong (Memory Leaks).

Four copies of one fact

Draw the copies before debugging anything. For an order's status there is the row in the database, the entry in the client cache, whatever a component copied into its own state at mount, and the status= in the address bar that decided which orders were fetched in the first place. All four can be different simultaneously, and each has a plausible-looking reason for its value.

The arrows are the design. In the shape below the server writes the cache, the cache feeds derivations, and the URL selects what to fetch — so there is exactly one path by which a value changes, and any disagreement is either a stated staleness window or a bug in one arrow. The dashed relationship — component state *copying* the cache — is the one to delete.

Who writes what, and in which direction
writes without telling youfilter / navigatederives the keyresponse / invalidationcopied once, never updatedrenders a stale valuetypeson submit, with a versiononly if it revalidatesThis userOther users / systemsURL: which dataForm draft: user is authoritativeServer: authoritativeClient cache: a copy with an ageAnother tab: a second cacheDerivations (safe)Component copy (the divergence)Rendered UI
UserLLMAgentToolDataDecisionHumanGuardrail

Which one is authoritative?

When the four disagree, the resolution is not "pick the newest" — it is "consult the stated owner for this value in this moment". The owner changes with the situation, which is why a single global rule cannot work.

The middle column is the part worth arguing about in a design review. Note the row where the *user* wins: while a form is dirty, the draft outranks the server, and a background update that overrules it is a bug no matter how fresh the data was.

SituationAuthoritativeEveryone else doesHow you detect the divergence
Steady state, nothing in flightThe serverHolds a copy with a stated ageCompare the cache entry's age against its staleness window
User is editing a formThe user's draftMust not overwrite it; queue or flag insteadA dirty flag on the form (Form State Is a Draft)
Optimistic update in flightThe client, provisionallyRenders the assumption plus a rollback pathA pending marker on the mutation (Optimistic UI)
Mutation response arrivesThe server responseReplaces the optimistic value and invalidates keysThe response body versus what was rendered
Two tabs openThe server, againEach tab holds an independent copyRevalidate on focus, or broadcast on mutation
Back navigationThe URLThe fetch key is re-derived from itA response whose key no longer matches the current URL
Two users edited the same recordThe server's version checkThe loser is told and shown the conflictA 409-style response surfaced as UI (Optimistic Concurrency: Versions and If-Match)
OfflineThe queued intent, provisionallyApplies locally; reconciles on reconnectQueue depth and per-item status (The Offline Mutation Queue)

Divergences and what each one actually is

The value of naming these is that each symptom points at a different arrow in the diagram. "The sidebar is stale" and "my typing was deleted" are both divergence, and they have nothing else in common.

Symptom to arrow
TriggerSymptomCauseResponse
A mutation succeeds and one view still shows the old valueThe table updates, the sidebar badge does notThat view reads a component copy, not a derivation of the cacheDelete the copy; subscribe to the same key (Derived State).
Two requests for one key overlapA newer list is replaced by an older oneResponses applied by arrival order rather than request orderCancel superseded requests and discard non-matching responses (Cancelling a Request Nobody Is Waiting For).
Background revalidation while a form is dirtyTyped characters vanish mid-sentenceThe cache wrote into the object the inputs were bound toEdit a draft copy and suspend revalidation while dirty (Form State Is a Draft).
A mutation fails after an optimistic updateThe row flickers back, sometimes to the wrong valueRollback restores a snapshot older than a newer successful changeRoll back by re-deriving from the server, not by replaying a snapshot (Rollback and Reconciliation).
A record edited in two tabsOne user's change disappears with no errorLast write wins on the server, with no version checkSend a version or ETag and surface the conflict in the UI (The Lost Update, Step by Step).
A real-time event and a refetch describe the same changeA count increments twiceTwo write paths into one cache entry with no identity checkApply events idempotently by event id (Ordering and Duplicate Delivery).
A tab left open overnightEverything looks fine and every action failsThe cache is stale and the session has expired underneath itRevalidate on focus and handle expiry as a first-class state (Session Expiry and the Refresh Race).

How to build it

Most important first.

  • Name the authority per value, in writing. "The server is authoritative; the cache is a copy with a five-minute staleness window; components derive, never copy." Almost every synchronisation bug is the absence of that sentence.
  • Make the data flow one-directional. Server writes the cache, the cache feeds derivations, derivations render. Anything that writes upstream is either a mutation or a bug (Derived State).
  • Give every fetched value a key derived from the URL and the parameters, so there is one entry per logical query and no way to hold two (Query Keys and Invalidation).
  • Invalidate on mutation rather than refetching everything. After a write, mark the affected keys stale and let the subscribed views revalidate (Query Keys and Invalidation).
  • Keep drafts out of the sync path entirely. A form edits its own copy and does not accept background updates while dirty (Form State Is a Draft).
  • Discard responses that no longer match the current request. Every async result must be checked against the state that asked for it before it is applied (Cancelling a Request Nobody Is Waiting For).
  • Decide the cross-tab story explicitly: revalidate on focus, broadcast on mutation, or accept divergence and say so. Accepting it is a legitimate answer; not deciding is not (Auth Across Tabs).
  • Use the server's concurrency primitives rather than inventing a client-side one. A version or ETag on the record turns a silent last-write-wins into a conflict you can show the user (Optimistic Concurrency: Versions and If-Match).

Keyboard, focus, semantics, announcement

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

  • A background update that replaces content under the user is disorienting for everyone and hostile to a screen-reader user, whose reading position is inside the content being replaced. Prefer showing that newer data is available and letting the user apply it (Live Regions and Announcement).
  • If content must update in place, keep focus where it was. Replacing a list wholesale destroys focus on the focused row and returns the keyboard user to the top of the document (Focus Management).
  • A row that changes underneath a keyboard user mid-action — arrow-keying to it, then activating it after a refetch reordered the list — activates the wrong thing. Stabilise ordering, and key rows by identity (Reconciliation and Keys).
  • Announce resolutions, not just failures. When a conflict is detected and the user's change was not saved, that must be an assertive announcement and a focusable message, not a toast that disappears in four seconds (Errors People Can Actually Perceive).
  • A value that disagrees across the page is worse for assistive technology than for sighted users: a screen-reader user encounters the two versions minutes apart, with no way to see that they are the same fact (The Accessibility Tree).

What can go wrong

Failure modes
  • Last-write-wins with no version check: two users edit the same record and the second silently erases the first's change (The Lost Update, Step by Step).
  • The mitigation failing: an invalidation rule that misses a key, so one view updates after a mutation and another keeps rendering the old value indefinitely.
  • Over-invalidation: every mutation clears the whole cache, so the app is always correct and always loading, and the network bill and interaction latency both climb.
  • A rollback restoring a stale snapshot on top of a newer successful change (Rollback and Reconciliation).
  • A stale response applied because nothing checked whether the request that produced it was still relevant (Out-of-Order Responses).
  • A cross-tab broadcast loop: tab A writes, tab B receives and writes, tab A receives and writes again.
  • A real-time stream and a polling refetch both writing the cache with different orderings, so the final value depends on arrival order rather than event order (Ordering and Duplicate Delivery).
What can arrive out of order
Security
  • Client-side reconciliation is never authoritative. If two clients disagree, the server decides — and it must decide with a version check, not by trusting whichever request arrived last (Optimistic Concurrency: Versions and If-Match).
  • An optimistic update that renders an action as successful before the server has authorized it shows the user a result they may not be entitled to, and can be used to probe what is permitted (Authorization-Aware UI).
  • Cross-tab broadcasts are same-origin only, which the browser does enforce — but anything else running on your origin can read them, including third-party scripts (Third-Party Scripts and the Supply Chain).
  • Cached server data lingers after logout unless it is explicitly cleared. A shared machine plus a persisted cache is a data-exposure path with no exploit required (Session Expiry and the Refresh Race).
Misreads
  • "Refetching fixes it." Refetching narrows the window. It does not decide who is authoritative, and it does not stop an older response from landing after a newer one.
  • "Optimistic updates cause this." They make the divergence deliberate and bounded. Undeclared copies scattered across components cause it.
  • "The cache is the source of truth." The cache is a copy with an age. Treating it as truth is how a completed order gets worked twice (Server State Is Not Your State).
  • "Real-time updates remove the need for reconciliation." A stream can drop, reorder or duplicate messages, so a resynchronisation path is more necessary with a stream, not less (Resynchronisation After a Gap).
  • "It only happens with multiple users." One user with two tabs, a slow request and a Back button reproduces every failure in this lesson.

Measuring it, and what changes in the field

How you would see this
  • Open the same view in two tabs, mutate in one, and watch the other. Whatever does not converge is a synchronisation decision you have not made (Auth Across Tabs).
  • The Network panel after a mutation: exactly the affected keys should revalidate. Nothing means missing invalidation; everything means over-invalidation.
  • Cache devtools — the query inspector in a caching library — show each key, its age and its subscribers. A key with two entries for the same logical query is a key-design bug (Query Keys and Invalidation).
  • Throttle the network and issue two overlapping requests deliberately. If the older response wins, you have an ordering bug in production too (Out-of-Order Responses).
  • Field error tracking for conflict responses tells you how often real users are actually colliding, which is usually either far more or far less than the team assumed (Frontend Error Tracking).
Slow device, slow network, large data, old tab
  • On a slow network, the window between the client's copy and the server's value is wide enough for the user to act inside it — which is precisely when optimistic UI is worth its rollback cost (Optimistic UI).
  • On a long-lived tab, the cache drifts arbitrarily far from the server and the session may have expired underneath it (Long-Lived Clients and Version Skew).
  • With many concurrent users on the same records, conflicts stop being theoretical and the version check becomes load-bearing (Optimistic Concurrency: Versions and If-Match).
  • Offline, every mutation is a deferred divergence and the reconciliation happens in a batch on reconnect (The Offline Mutation Queue).
  • Across a deploy, an old tab holds a cache shaped by the previous release and talks to the new API (Deploying a Frontend).
What this costs
  • One-directional flow with explicit invalidation is more machinery than setting values where you find them, and it makes some updates arrive a beat later than a direct write would.
  • Freshness costs requests. Every reduction in staleness is paid for in bandwidth, battery and main-thread time, and there is no setting that is correct for every value on the page.
  • Optimistic updates buy responsiveness with a rollback path, an error surface and a class of bug that only appears when the server disagrees (Rollback and Reconciliation).
  • Cross-tab synchronisation adds a messaging layer, a loop risk and a per-tab cost, for a scenario many products can legitimately decide not to support.

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.

  • GENERALThat four layers can hold one fact, and that responses can arrive out of order, follows from the browser's asynchrony and the network — it is true in any framework and in plain fetch. Only the tooling that manages it differs.
  • FRAMEWORK-SPECIFICThe cache layer is a library decision, not a framework one, and the libraries disagree about defaults. TanStack Query and SWR revalidate on window focus by default and expose per-key staleness; RTK Query centres tag-based invalidation instead; Angular's httpResource and resource primitives are signal-shaped and leave staleness policy to you; SvelteKit answers a large part of this with server load functions plus invalidate, so the client cache is smaller and the server does more. Advice about "stale time" is meaningless without naming which of these is in play.
  • SIMPLIFIEDThe four-layer model omits layers real applications add: HTTP caching in the browser, a service worker cache, and any server-side render cache. Each is another copy with its own age, and the ownership argument applies unchanged to all of them (Browser HTTP Caching, Caching Strategies).

Where the depth lives

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

Domains that do not exist yet
  • Distributed Systems — a browser tab is a replica. Two tabs plus a server is a three-node system with no consensus protocol, and the vocabulary of staleness bounds, causal ordering, last-write-wins and conflict resolution describes this exactly.
  • Testing & Reliability Engineering — reproducing a divergence deterministically means controlling response ordering, which is a test-harness capability rather than an application one.