ProductionGENERALPLATFORM-SPECIFIC

Long-Lived Clients and Version Skew

Production is running client v1, client v2 and backend v3 at the same time. There is no moment at which the frontend updates.

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 deploying does not update the tabs that are already open, what is actually running in production right now?

The user intent

Someone opened your application on Tuesday morning, left the tab open behind eleven others, and came back on Thursday. They expect it to still work — not to lose the draft they were halfway through.

The obvious build

We deployed, so everyone is on the new version. The frontend and the backend ship together, so the contract is whatever the current code on both sides says it is.

Why it breaks

A deploy publishes; it does not update anyone. A tab keeps running the build it loaded until its user reloads, and a large fraction of users never deliberately reload anything (Deploying a Frontend).

How it breaks in a real browser
  • A deploy publishes; it does not update anyone. A tab keeps running the build it loaded until its user reloads, and a large fraction of users never deliberately reload anything (Deploying a Frontend).
  • The moment a field is renamed on the server, every client build that reads the old name breaks — and "every client build" includes ones you removed from the repository months ago.
  • A response that gains a field is fine; a response that changes the *type* of a field, or nests a value one level deeper, crashes an old client at the point where it indexes into the shape it was compiled against.
  • A removed chunk 404s for the old tab the first time its user opens a route they had not visited yet (Lazy Loading).
  • Persisted client state outlives the build that wrote it. A schema change in localStorage or IndexedDB means this release's code reading last release's data — and, because two tabs can be two builds, also last release's code reading this release's data (Persistent Client State).
  • An offline mutation queued by build N is replayed after build N+1 changed the request body, so the replay is rejected by a server that is entirely correct (The Offline Mutation Queue).
  • A security fix in the client is not deployed when it is deployed. It is deployed to each user at whatever future moment they happen to reload.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A web client has no atomic update. Native apps have an install step; a web application has a document that was fetched once and a JavaScript heap that has been alive ever since. Deployment changes what the *next* load receives, and nothing else.
  • So production is not a version, it is a set: {client builds N-k … N} × {backend version M}, with every combination live simultaneously. The interesting question is never "what version is deployed" but "what is the oldest client we are still willing to serve".
  • The real consumer set of your API is every client build still running, not the newest one. That is what makes frontend deployment a compatibility problem rather than a packaging one (How API Shape Drives UI Complexity).
  • Compatibility runs in two directions and they are not symmetric. The server must remain backward-compatible with clients it can no longer change. The client must be forward-tolerant of servers it has never seen — which in practice means ignoring fields it does not recognise instead of rejecting them.
  • Three separate channels carry skew, with three different lifetimes: assets (until the tab reloads), the API contract (until the last old build is gone), and persisted state (potentially forever, because storage survives every deploy and most reinstalls).
  • A tab can also come *back* from the dead. A page restored from the back/forward cache resumes a live JavaScript heap from a build that may be several deploys old, with its timers and its sockets (History and Navigation).

What this makes the browser do

And which of it is avoidable.

  • Nothing. That is the point: the browser does no work at all to adopt a deploy, because from its perspective nothing happened. The document is still the document.
  • A service worker may make it worse rather than better, serving a cached build long after the network has moved on (Caching Strategies).
  • On reload, the browser refetches the document, discovers a different asset graph, and pays a full cold parse and compile for everything that changed (The Real Cost of JavaScript).
  • Across tabs, storage events and shared IndexedDB connections mean two different builds are reading and writing the same records concurrently (localStorage and sessionStorage).
  • Restoring a page from the back/forward cache resumes the old heap directly — no fetch, no parse, no chance for new code to run first.

What is actually running in production

Draw the population rather than the pipeline. At any instant your backend is talking to a spread of client builds, each of which was frozen at the moment its document was fetched. The newest build is usually not even the majority for the first hour after a release, and the oldest one still making requests is routinely older than anyone on the team expects.

Once you draw it this way, several arguments stop being arguments. "Is this a breaking change?" becomes "does any live build depend on the old shape?", which is a query. "When can we delete this?" becomes "when has the adoption curve cleared the support window?", which is a date. And "why did that user see something impossible?" usually resolves to a combination on this diagram that nobody had considered.

  • Assets skew until the tab reloads. Mitigated by retention: keep old builds published.
  • The API contract skews until the last old build is gone. Mitigated by additive change and a measured support window.
  • Persisted state skews indefinitely — it survives deploys, reloads and often reinstalls. Mitigated by an explicit schema version and forward migration (Persistent Client State).
  • Feature flags skew on their own schedule, independently of any deploy, and can change behaviour inside a session (Feature Flags in the Client).
  • Open sockets carry a protocol version chosen at connect time and may outlive several deploys (WebSockets in the UI).
One backend, many clients, three channels of skew
asks for N-2 chunksmay never askasks for N chunksreads schema v3writes schema v4below floor → ask to reloadTab: build N-2 (opened Tuesday)Tab: build N-1 (service worker cached)Tab: build N (loaded just now)Persisted state (shared across tabs, survives deploys)CDN: assets for builds N-3 … NSupport floor: oldest build servedBackend version M
UserLLMAgentToolDataDecisionHumanGuardrail

The contract is with every build still running

The practical discipline is expand-and-contract, borrowed wholesale from database migrations and just as non-negotiable here. Expand: add the new shape alongside the old one and have the server populate both. Wait: let the adoption curve clear the support window. Contract: remove the old shape once telemetry says no live build reads it. The waiting step is the one teams skip, and it is the entire mechanism.

The client half is less obvious and just as important. A client that validates responses exhaustively — rejecting anything with a field it does not recognise — has made every future additive server change into a client outage. Parse what you need; ignore the rest; fail loudly only when something you actually require is missing or the wrong type.

Changing a response shape, with old clients live
Breaking, deployed on a Thursday
// v3 response
{ "id": 7, "name": "Ada Lovelace", "email": "ada@example.com" }

// v4: "split the name properly"
{ "id": 7, "firstName": "Ada", "lastName": "Lovelace",
  "contact": { "email": "ada@example.com" } }

// Every client build older than v4 now renders
// "undefined" where the name was, and throws when it
// reads user.email.length.
Additive, contracted weeks later
// v4: add, do not move. Server populates both.
{ "id": 7,
  "name": "Ada Lovelace",        // deprecated, still correct
  "firstName": "Ada",
  "lastName": "Lovelace",
  "email": "ada@example.com",     // deprecated, still correct
  "contact": { "email": "ada@example.com" } }

// New clients read the new fields. Old clients keep working.
// Removal is a separate change, gated on:
//   requests-with-old-build-id == 0 for the support window.

The rename is not wrong as a design; it is wrong as an *event*. Old clients cannot be asked to change, so the only safe sequence is add, wait for adoption, then remove — and the waiting is measured against the client build distribution, not against a sprint boundary (Backward Compatibility: The Real Rules).

Send who you are; tolerate what you do not know
1// Build id is injected at build time and is public. It is telemetry,
2// never a credential and never an authorization input.
3const BUILD = __BUILD_ID__
4
5async function apiFetch(path: string, init?: RequestInit) {
6 const res = await fetch(path, {
7 ...init,
8 headers: { ...init?.headers, 'X-Client-Build': BUILD },
9 })
10
11 // The server can tell a client it is past the support floor.
12 // We surface an offer to reload — we do not reload.
13 if (res.status === 426) {
14 upgradeAvailable.set({ reason: 'unsupported-client' })
15 throw new ClientTooOldError()
16 }
17 return res
18}
19
20// Forward-tolerant read: require what you use, ignore the rest.
21function toOrder(raw: Record<string, unknown>): Order {
22 if (typeof raw.id !== 'string') throw new ContractError('order.id')
23 return {
24 id: raw.id,
25 // new field with a fallback to the deprecated one
26 total: typeof raw.totalMinor === 'number'
27 ? raw.totalMinor
28 : Math.round(Number(raw.total ?? 0) * 100),
29 // unknown status values must not crash a screen
30 status: KNOWN_STATUSES.has(raw.status as string)
31 ? (raw.status as OrderStatus)
32 : 'unknown',
33 }
34}

The unknown status branch is the part that matters. An enum gains members; a client compiled before a member existed must render *something* sensible rather than falling off the end of a switch (Enum Evolution: The New Value That Broke Old Clients).

Telling an old client to reload, without taking their work

Every team eventually needs to move a user forward: a security fix, a contract removal, a bug that only a reload clears. The engineering question is not whether to prompt but what the prompt is allowed to do, and the answer is much narrower than it first appears. It may inform, it may offer, and it may act on the next navigation. It may not interrupt, and it may not discard anything the user has typed.

Treat it as a spectrum of escalation rather than a boolean. Most releases need nothing at all — the user will reload eventually. Some need a visible, patient offer. A few genuinely need to stop an old client from continuing, and for those the honest move is to make the affected action fail with a clear explanation rather than to yank the page out from under a session.

accessibility specPersistent update notice — an offer, not a dialogThe "new version available" affordance

semantics A region in a stable place with a real heading or label, containing an ordinary button. Announced through a polite live region. Not role="alertdialog", not role="alert", not a focus trap: nothing about it is urgent enough to interrupt.

TabReaches the Reload and Dismiss buttons in document order — never by being force-focused.
Enter / SpaceActivates Reload, which is a normal button activation and a normal navigation.
EscapeDismisses the notice if it is dismissible. It must not be the only way to get past it.
Focus
  • Focus stays exactly where the user left it when the notice appears. Moving it is the defining mistake of this pattern.
  • If the user activates Reload, save the route, the scroll position and any unsaved input first, and restore them after the load.
  • After a dismiss, focus returns to what it was before the notice was reached, not to the top of the page.
Announces
  • Polite announcement on appearance: "A new version of the application is available."
  • If the client is past the support floor and an action has been refused, that refusal is announced where the action was, not only in the notice (Errors People Can Actually Perceive).
  • Nothing is re-announced on a timer. One polite announcement, then silence.

usually broken by The pattern is almost always broken by making it a modal or an auto-dismissing toast. A modal steals focus from someone mid-edit and blocks the page for a message that could have waited; a toast vanishes before a screen-reader user, a magnification user, or anyone who looked away has had a chance to reach it — leaving them with an application that quietly stops working and no explanation they can find.

How hard should you push an old client forward?

A build is live that you would rather users were not running. What do you do about it?

Nothing — let it age out

when The change is ordinary: a feature, a style fix, a non-breaking API addition. The old build still works correctly.

cost A long tail you must keep supporting: assets retained, old contract honoured, old build ids in your error reports.

Passive notice

when You want adoption to accelerate but nothing is broken. The default for most releases that matter.

cost Most users ignore it. Adoption improves; the tail does not disappear, so you still need the support window.

Reload on next navigation

when A route change is already discarding view state, so a full document load costs the user almost nothing extra.

cost Client-side routing stops being seamless for one navigation; in-memory state that survived route changes is lost (Client-Side Routing).

Refuse the specific action

when One operation is genuinely unsafe from an old build — a changed payment shape, a removed field, a fixed validation bug.

cost You must write a real, specific error message and a recovery path. A generic failure here is worse than the original bug.

Hard block below a floor

when A security fix, or a contract removal you have already waited out. The old client must stop.

cost Guaranteed to catch someone mid-task. Needs draft preservation, a clear explanation, and a service worker strategy that cannot serve the old build back (The Service Worker Lifecycle).

How to build it

Most important first.

  • Change the API additively. Add fields, add endpoints, add enum members with a documented fallback. Never repurpose a name, never change a type in place, never move a value deeper. Removal is a separate, later, measured step (API Migration: Running the Change End to End).
  • Tolerate the unknown on the client. Parse what you need and ignore what you do not; never validate that a response has *exactly* the keys you expect. An exhaustive client-side schema check turns every additive server change into an outage (The Life of a Fetch).
  • Send a build identifier with every request and log it server-side. The distribution of build ids hitting your API is the only honest answer to "can we remove this field yet".
  • Negotiate a floor, not a version. Define the oldest build you support; when a request arrives from below it, respond in a way the client can act on rather than failing opaquely.
  • Ask, do not force. A client past the floor should show a persistent, dismissible "a new version is available" affordance and reload on the user's command — or, at most, automatically on the next navigation, never mid-edit.
  • Version persisted state explicitly. Store a schema version alongside the data, migrate forward on read, and treat a version *newer* than the running code as read-only rather than something to repair (IndexedDB).
  • Retain assets and source maps for the whole support window. An error report from a build whose source map you deleted is a stack trace of minified names (Source Maps).
  • Make the support window a written policy with a number in it, and measure adoption against it. Without one, "old client" means "older than whatever the person debugging assumes".

Keyboard, focus, semantics, announcement

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

  • The "new version available" affordance is the accessibility surface of this lesson, and the temptation is to make it grab attention. It must not: moving focus to it interrupts whoever is mid-sentence in a text field, and an alert role interrupts a screen reader's current utterance for something that is not urgent (Live Regions and Announcement).
  • Make it a persistent, keyboard-reachable control in a stable place, announced politely, that stays until acted on. A toast that disappears after a few seconds is unusable for anyone reading the page linearly or using magnification (Focus Management).
  • A tab that has become non-functional — a chunk that will not load, an API that now rejects it — must say so in text that is announced, not merely by controls that quietly stop working. Silence is indistinguishable from slowness (Errors People Can Actually Perceive).
  • If the user does reload, restore where they were: the route, the scroll position and, where you can, the unsaved input. For someone navigating by keyboard or screen reader, being returned to the top of a re-rendered page is a substantial re-orientation cost (Scroll Restoration).
  • Never let a reload prompt trap focus or block the page. It is an offer, not a dialog, and someone must be able to keep working past it (The Rules of ARIA).

What can go wrong

Failure modes
  • The reload prompt reloads automatically, or after a countdown, and discards a form the user had been filling in for ten minutes. This is the most common way a skew mitigation becomes worse than the skew.
  • Forward-tolerant parsing hides a genuine contract break: the field really did change meaning, the client silently reads undefined, and the UI renders an empty value instead of an error. Tolerance must be scoped to *unknown* fields, not to missing required ones.
  • A migration runs in the tab that reloaded, rewriting persisted state into the new schema — and the user's other tab, still on the old build, reads it and crashes.
  • The "please reload" path is served by a service worker holding the old build, so the reload returns the same old client, which asks again, which reloads again (The Service Worker Lifecycle).
  • A field is removed after telemetry shows "no traffic", but the telemetry was sampled, or the remaining users are on a build that only reads that field on one rarely-visited screen.
  • Skew is treated as transient and tested only on freshly loaded pages, so the entire class of bug is invisible in CI and in every manual QA pass (End-to-End Testing).
What can arrive out of order
  • A deploy prunes assets while an open tab is about to request one — the module's signature race (Deploying a Frontend).
  • Two tabs of two different builds write the same persisted key. Whichever wrote last wins, and neither build knows the other exists.
  • A schema migration in a reloaded tab races a read in a tab that has not reloaded.
  • A flag flip lands between two components on the same screen, so one rendered the old branch and the next renders the new one (Feature Flags in the Client).
  • An offline queue built by an old build is replayed against a server whose contract has since moved (The Offline Mutation Queue).
  • A session is revoked server-side while an old tab is mid-flight, so a request authorised at send time is rejected on arrival (Session Expiry and the Refresh Race).
Security
  • Patching the client does not patch the population. A cross-site scripting fix, a fix to a token-handling bug, or a corrected redirect validation only protects sessions that have reloaded since. Client-side security fixes need a *forced* upgrade path, which is exactly the thing you otherwise avoid (Cross-Site Scripting).
  • Old clients are not less trusted by the server unless you make them so. Every rule the new client enforces was already unenforceable in the browser, so the server's checks must cover the union of every client's behaviour, not the newest one's (What the Frontend Is Responsible For in Auth).
  • A client-supplied build identifier is telemetry, never a control. It is set by code running on the user's machine and can be anything; use it to measure the population, never to grant anything (The Browser Security Model).
  • Persisted state written by an old build may hold data under an old classification or an old retention assumption — tokens with scopes you have since narrowed, cached records you no longer keep server-side (Storage Security and Durability).
  • A long-lived tab holds a session for as long as it is open. Expiry, revocation and "sign out everywhere" must be enforced server-side, because the old tab will keep presenting its credential cheerfully (Session Expiry and the Refresh Race).
Misreads
  • "We deploy the frontend and backend together, so there is no skew." The *deploy* is simultaneous. The clients are not, and they are the half you do not control.
  • "Nobody uses that field." Nothing in the *current build* uses it. That is a different sentence, and only the API access logs can turn one into the other (Removing Fields Without Removing Consumers).
  • "A forced reload solves it." It solves your problem by creating the user's: lost input, lost scroll position, lost place in a task.
  • "This is an SPA problem." A multi-page application reduces the asset half of it, and does nothing about persisted state, an open form, or a client running an old copy of your validation rules (MPA vs SPA).
  • "Feature flags handle version skew." Flags are another source of it: they change behaviour under running clients without any code changing at all (Feature Flags in the Client).
  • "It is a transient window right after deploy." The window is as long as your longest session, and your longest session is longer than your deploy cadence.

Measuring it, and what changes in the field

How you would see this
  • The adoption curve: share of live sessions per build id, plotted over the hours and days after a release. It is the single chart that makes this lesson concrete to a team that does not believe it.
  • The age distribution of live sessions — time since the document was loaded. The tail is longer than anyone guesses, and it is what the support window has to cover.
  • Requests per client build id at the API, which converts "can we remove this field" from an argument into a query (Real User Monitoring).
  • Error and 4xx rate segmented by client build, so a spike in one old build is distinguishable from a general regression (Release Health).
  • Chunk-load 404s per build, which is the direct measurement of the asset-retention window being too short (Frontend Error Tracking).
  • Persisted-state schema versions observed in the field, reported at startup. Teams are routinely surprised by how many versions are still out there.
Slow device, slow network, large data, old tab
  • On a dashboard, an admin console or anything people keep on a second monitor, sessions measured in days are normal rather than exceptional.
  • On mobile, a tab is often suspended rather than closed, and can be resumed weeks later from the back/forward cache with its old heap intact.
  • With a service worker or an installed application, the client controls its own update timing, and a badly designed update strategy can pin a user to a build indefinitely (Manifest and Installability).
  • On a kiosk, an embedded webview or a machine that is never rebooted, the "long tail" is not a tail — it is a permanent resident.
  • In a micro-frontend deployment, skew is internal too: two units in the same page, deployed a week apart, sharing a design system version and a state contract (Micro Frontends).
What this costs
  • Additive-only evolution accumulates cruft. The API grows deprecated fields, the client grows fallbacks, and both carry them for as long as the support window says. That cost is real and it is the price of not breaking people.
  • A support window is a testing matrix. Honouring it means at least occasionally exercising an old build against the current backend, which nobody enjoys building and everybody needs.
  • Version negotiation adds a header, a server-side branch and a client state machine, and it introduces a new way to fail: telling a client it is unsupported when it is not.
  • Forward-tolerant parsing costs you a class of early error detection. You will find some contract mismatches in production that a strict client would have caught in staging.

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 a web client adopts a deploy only on reload follows from the document model itself and holds for every browser, framework and rendering strategy. What differs is only how long the tail is for a given product.
  • PLATFORM-SPECIFICThe tail length is set by usage, not by the web: a consumer site visited briefly has a tail of minutes, an internal dashboard on a second monitor has one of days, and an installed application or embedded webview controls its own update timing and can pin a build indefinitely. The mechanism is identical; the support window you must fund is not.

Where the depth lives

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

Domains that do not exist yet
  • Distributed Systems — this is version skew in a system where you own one node and rent the rest. Old clients are replicas you cannot upgrade, the API contract is the protocol between them, and expand-and-contract is the rolling-upgrade discipline that keeps mixed versions interoperable.
  • Distributed Systems — two tabs of two builds writing one persisted key is last-writer-wins on a replicated store, with all the conflict-resolution questions that implies.
  • Testing & Reliability Engineering — testing an old client build against the current backend as a scheduled compatibility check rather than a thing nobody ever does.