OfflineGENERALPLATFORM-SPECIFICBROWSER-SPECIFIC

Offline UX

Honesty is the whole lesson: say what is available, what is stale, what is queued, and what will happen on reconnect — because a UI that pretends to be online is worse than one that admits it is not.

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 network is gone or unreliable — what does the interface owe the person using it?

The user intent

Someone is editing a document in a lift. They want to keep working, and they want to know, without having to guess, whether what they just typed is safe.

The obvious build

Listen for online and offline, show a grey banner saying "You are offline" when the event fires, and otherwise behave normally.

Why it breaks

navigator.onLine means "there is a network interface that could carry traffic" — not "your server is reachable". A device on hotel wifi behind a captive portal reports online and can reach nothing.

How it breaks in a real browser
  • navigator.onLine means "there is a network interface that could carry traffic" — not "your server is reachable". A device on hotel wifi behind a captive portal reports online and can reach nothing.
  • The opposite happens too: a VPN or interface change fires offline while requests are still succeeding, so the banner appears on a working connection and users learn to ignore it.
  • The banner says "offline" and the rest of the UI carries on as if nothing changed: buttons look enabled, saves appear to work, and nothing says where the data went.
  • The user hits Save, sees a spinner, and the spinner never ends — because the request is queued and the UI has no state for "queued", only for "loading".
  • On reconnect everything syncs silently, and the user has no way to know whether the three edits they made in the tunnel arrived, arrived in order, or were overwritten (The Offline Mutation Queue).
  • Cached content is rendered exactly like live content, so the user acts on a price, a stock level or a message thread from an unknown point in the past.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • The browser exposes navigator.onLine plus online/offline events. They reflect the connection state of the device, not reachability of your origin, and they are a hint, not a signal (Network Failures Only the Client Can See).
  • The only reliable evidence that your server is reachable is a request to your server that succeeded. Everything else is inference.
  • A failed fetch distinguishes almost nothing by itself: DNS failure, connection refused, timeout and a CORS rejection all surface as a generic TypeError. Status codes only exist once a response arrived.
  • A service worker can turn a network failure into a *typed* application response — a synthesised body your data layer understands — instead of an exception at the call site (Intercepting Fetch).
  • Therefore the app needs its own connectivity model with more than two states: reachable, degraded (requests slow or intermittently failing), unreachable, and unknown-since-last-success.
  • And it needs a per-item state distinct from that: fresh, stale (served from cache at a known time), pending (queued locally), failed (rejected by the server), conflicted (the server has a different version).
  • The Page Visibility and pageshow events matter here: a tab restored from the back/forward cache can resume with a connectivity model that is minutes out of date and must re-verify rather than assume.

What this makes the browser do

And which of it is avoidable.

  • Nothing renders these states for you. Every one is application state you keep, derive and display.
  • A polling reachability check costs a request per interval on a connection that may be metered; back it off when the tab is hidden.
  • Re-rendering a status region on every connectivity flap can thrash a live region and produce a stream of announcements. Debounce the *state*, not just the pixels.
  • Rendering a "3 changes pending" indicator means the queue count is now UI state that must stay in sync with durable storage, which is itself a source of bugs (State Synchronization).

Two states is not enough

The banner model has one bit of state and puts it in one place. Real offline UX has two independent axes: how reachable the server is, and what the status of *this particular thing* is. A document can be stale while the app is online, and pending while the app is offline, and both facts matter to the person looking at it.

The rewrite below is not longer because it is more careful; it is longer because it says three true things instead of one misleading one.

  • Reachable / degraded / unreachable / unknown — a property of the app, derived from your own requests succeeding, not from navigator.onLine.
  • Fresh / stale — a property of each piece of content, with the time it was fetched.
  • Pending / failed / conflicted — a property of each change the user made, which outlives the page.
  • These three do not collapse into one indicator. A single "offline" pill cannot express "online, but the thing you are reading is from yesterday and two of your edits have not sent".
A save button, offline
Pretends nothing changed
<div class="banner">You are offline</div>

<button onclick="save()">Save</button>
<!-- save() resolves locally; the UI shows a tick -->
<span class="tick" aria-hidden="true">&#10003;</span>
Says what is true
<div role="status">
  Can&rsquo;t reach the server. Showing your last saved copy from 09:14.
</div>

<button onclick="save()">Save</button>
<p id="save-state" role="status">
  Saved on this device. 3 changes will sync when you reconnect.
</p>

<!-- and, per item that is not current -->
<article aria-describedby="stale-note">
  <p id="stale-note">Last updated 09:14, before you went offline.</p>
</article>

The first version makes a promise the app cannot keep: a tick that a user reads as "the server has it". The second separates reachability, staleness and pending work — three facts that change independently — and puts each one in text that a screen reader announces and a sighted user can read without decoding a colour.

Announcing it, not just showing it

Connectivity status is exactly the kind of information that gets built as an icon and a colour, and exactly the kind that is useless as an icon and a colour. It changes without user action, which is what live regions are for, and it is often the only explanation for why something the user just did has not happened.

The restraint matters as much as the announcement. A live region that fires on every flap turns into noise, and noise is dismissed. Announce settled state, not transitions in progress.

accessibility specConnection status region with a queued-changes indicatorConnectivity and sync status

semantics A role="status" (polite live region) for connectivity and sync transitions; the queued indicator is a button with an accessible name carrying the count and meaning, opening a list of pending changes. Never a bare <span> with a numeral.

TabReaches the queued-changes control in normal document order — it is not a decoration to be skipped.
Enter / SpaceOpens the list of pending changes: what is queued, when it was made, and what will happen on reconnect.
EscapeCloses that list and returns focus to the control that opened it.
Focus
  • Focus never moves when connectivity changes — a banner appearing must not interrupt someone typing.
  • If a conflict needs a decision, do not steal focus; announce assertively and provide a control the user can reach deliberately.
  • Opening the pending-changes list moves focus into it; closing returns focus to the trigger.
Announces
  • "Can't reach the server. Your changes are being saved on this device." — polite, once, on settling into the unreachable state.
  • "Back online. Syncing 3 changes." — polite, on settling into reachable with a non-empty queue.
  • "All changes synced." or "1 change needs your attention." — polite for the first, assertive for the second, because the second requires action.
  • Stale content announces its age through the content's own description, not through the connectivity region.

usually broken by The pattern invites two mistakes. First, announcing every flap, so a user in a lift hears a stream of contradictory statements and stops listening. Second, expressing the whole thing as a coloured dot with aria-hidden="true" on the only text that explains it — which is how a status indicator ends up with no accessible name at all.

Detecting reachability honestly

The rule is asymmetric and worth stating precisely: navigator.onLine === false is good evidence you are offline. navigator.onLine === true is no evidence of anything. Build on the asymmetry — use the false case as a cheap fast path, and use your own successful requests as the only positive signal.

A reachability probe should hit your origin, be cheap, and verify the *shape* of what comes back. A captive portal will happily return 200 with a login page, so "a response arrived" is not the test; "a response arrived and it is ours" is.

What the connection signal actually tells you
TriggerSymptomCauseResponse
Captive portal on public wifiApp reports online, every request returns someone else's login page with status 200The interface is up and the portal intercepts; navigator.onLine is true and the responses are not yoursProbe your own origin and validate the response shape, not just its status; treat a mismatch as unreachable.
VPN connects or an interface changesoffline fires while requests keep succeedingThe event reflects interface state, not reachabilityNever change UI state on the event alone — treat it as a hint to re-probe.
Server is down, network is fineReported online, everything failsNo client-side signal distinguishes this from a routing problemDerive from request outcomes: repeated failures against your origin mean unreachable regardless of what the property says (Network Failures Only the Client Can See).
Connection is up but unusably slowRequests hang; no error and no responseThere is no event for degraded, and fetch has no default timeoutTime out with AbortSignal, model a degraded state, and fall back to cache before the user gives up (Cancelling a Request Nobody Is Waiting For).
online fires on reconnectThe first queued request fails immediatelyThe event can precede usable connectivityProbe before flushing, and treat an early failure as "still offline" rather than as a rejected mutation.
Tab restored from back/forward cacheStale "online" state, stale data, no refetchThe page resumed with state from before it was frozenRe-probe and revalidate on pageshow and on visibility change, rather than trusting the last known state.

How to build it

Most important first.

  • Derive connectivity from your own requests. Treat navigator.onLine === false as a strong hint that you are offline, and true as no information at all.
  • Show three separate facts, not one: is the app reachable, how old is what I am looking at, and what of mine has not been saved yet. They change independently.
  • Timestamp stale content in words a person can act on — "showing data from 09:14" — rather than a coloured dot. The dot is for people already watching; the text is for everyone else.
  • Give queued work a state of its own, visually and semantically distinct from loading. "Saving…" and "Saved on this device, will sync when you are back" are different promises (Optimistic UI).
  • Disable — or better, explain — what genuinely cannot work offline, rather than letting it fail on click. A payment button that queues is a lie; a payment button that says why it is unavailable is not.
  • Say what will happen on reconnect, before it happens, and confirm it after: "3 changes will sync when you reconnect" then "3 changes synced" or "1 change needs your attention".
  • Never invent a success. If the write is local, the message says local. The single most damaging offline pattern is a green tick that means "we stored your intent" and reads as "the server has it".

Keyboard, focus, semantics, announcement

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

  • Connectivity and sync status must be announced, not merely shown. A colour change or an icon swap is invisible to a screen-reader user and ambiguous to anyone with a colour-vision deficiency (Live Regions and Announcement).
  • Use a polite live region for transitions — offline, reconnected, synced — and reserve assertive for something the user must act on now, such as a conflict awaiting a decision. Announcing every flap is worse than announcing none.
  • The queued-changes indicator needs an accessible name that carries the count and the meaning: "3 changes waiting to sync", not an unlabelled badge with a numeral in it.
  • A page silently serving stale cached content gives assistive-technology users no signal at all. The staleness must be in the accessible name or in text near the content, not implied by a dimmed style (Semantics Before ARIA).
  • Anything disabled because it needs the network must say why in its accessible name or via aria-describedby. A bare disabled attribute removes it from the tab order and explains nothing.
  • Do not move focus when connectivity changes. A person typing in a field should not be interrupted because a banner appeared (Focus Management).

What can go wrong

Failure modes
  • The permanent banner: a device that reports offline on a working connection, so the warning is always up and always ignored.
  • The silent queue: work accumulates locally, the user closes the tab believing it saved, and it is either lost or syncs days later from a device they have forgotten about.
  • The optimistic lie: the UI shows the change as saved, the server later rejects it, and there is no path back to the user who caused it (Rollback and Reconciliation).
  • The infinite spinner: a queued request modelled as "loading", which by definition never resolves offline.
  • The reconnect stampede: connectivity returns and every queued request, every refetch and every retry fires at once (Retries, and the Duplicate Order).
  • The stale render with no marker: cached data presented identically to live data, so nobody can tell and nobody asks.
  • The mitigation failing: an "offline mode" toggle the user must find, which means the people most affected — the ones who did not know they were offline — never turn it on.
What can arrive out of order
  • Reconnect racing a refetch: connectivity returns, the app refetches a list, and the queued mutation for an item in that list has not been sent yet — so the fresh server data overwrites the user's pending edit in the UI (Out-of-Order Responses).
  • An online event racing actual reachability: the event fires before the interface can carry traffic, so the first flush of the queue fails and must not be treated as a rejection.
  • Rapid flaps producing interleaved announcements, so a screen-reader user hears "offline, reconnected, offline" in an order that no longer matches the current state.
  • Two tabs of the same app disagreeing about connectivity and both trying to flush the same queue (Auth Across Tabs has the same shape).
Security
  • An offline UI often means data sitting in browser storage on a device you do not control. What you queue locally is what an attacker with the device gets (Storage Security and Durability).
  • Clear queues and caches on logout. Otherwise the next user of a shared device can see — or unknowingly sync — the previous user's pending work (Session Expiry and the Refresh Race).
  • Never make an authorization decision offline. The client may hide an action for usability, but only the server can refuse it, and a queued mutation must be re-authorized when it finally lands (What the Frontend Is Responsible For in Auth).
  • A queued mutation carries a credential that may expire before it is sent. Refresh at send time, and design for a queue item that is now unauthorized rather than assuming the session survived the tunnel.
Misreads
  • "navigator.onLine tells me if the user is online." It tells you the device believes it has a network interface. Captive portals, VPNs and dead uplinks all report online.
  • "Offline UX is a banner." The banner is the least important part. The states attached to each piece of content and each pending change are the lesson.
  • "Optimistic UI and offline support are the same thing." Optimistic UI assumes the server will agree in a moment. Offline support assumes nobody has asked the server yet, and may not for hours (Optimistic UI).
  • "If it syncs eventually the user does not need to know." They chose to make that change; they are entitled to know where it is. Silent eventual consistency is how work gets lost without anyone noticing.
  • "Showing stale data is bad." Showing stale data is usually right. Showing it *without saying so* is what is bad.

Measuring it, and what changes in the field

How you would see this
  • Instrument the connectivity state machine itself: how often sessions enter degraded and unreachable states, and how long they stay (Real User Monitoring).
  • Track queue depth and queue age in the field. A p99 queue age measured in days means a population of users whose work is not where they think it is.
  • Count failed requests by cause, separating "no response" from "response with an error status" — they need different UI and they are different incidents (Network Failures Only the Client Can See).
  • Devtools can force offline, but a captive-portal-style failure (connected, requests hang) is the case worth reproducing and the one the offline checkbox does not simulate (A Mental Model of the Devtools).
Slow device, slow network, large data, old tab
  • On mobile, connectivity flaps constantly: lifts, tunnels, cell handoffs. The state machine must tolerate rapid transitions without producing a stream of banners and announcements.
  • On a captive portal, requests return a login page with a 200. Every response is "successful" and none of them are yours — which is why reachability checks should verify the shape of the response, not just that one arrived.
  • On a long-lived tab, the connectivity model can be hours old at the moment the user acts. Re-verify on visibility change rather than trusting the last known state (Long-Lived Clients and Version Skew).
  • With a large queue, "sync on reconnect" is not instantaneous, and the UI needs progress rather than a binary syncing flag.
What this costs
  • Honest states are more states: fresh, stale, pending, failed, conflicted — each needs a design, a string, an announcement and a test. It is genuinely more work than a banner.
  • Telling the user data is from 09:14 can reduce trust in the moment. It buys trust over time, and it prevents the far worse outcome of acting on stale data unknowingly.
  • Active reachability checks give a faster, more accurate signal and cost requests on a possibly metered connection.
  • Disabling offline-impossible actions is clear and can feel restrictive; the alternative — letting them fail after the fact — is worse but looks more capable in a demo.

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 unreliability of navigator.onLine is not a browser quirk — it is what the property is specified to mean, so no engine reports reachability of your origin and none ever will.
  • PLATFORM-SPECIFICHow aggressively connectivity flaps depends on the platform and radio: a mobile device on a cell handoff or a lift produces transitions a desktop on ethernet never sees, and an OS-level captive portal check may reconnect the interface before your origin is reachable.
  • BROWSER-SPECIFICThe devtools offline toggle simulates a hard disconnection; it does not reproduce the more common degraded case where requests hang or a portal answers them, so a UI tested only with that checkbox has been tested against the easy failure.

Where the depth lives

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

Securitysessions
Domains that do not exist yet
  • Distributed Systems — an offline client is a partitioned replica, and every honest offline UI is a user-facing rendering of eventual consistency: what is converged, what is not, and what needs a human to decide.
  • Software Design — "fresh, stale, pending, failed, conflicted" is a domain model, not a set of CSS classes, and it belongs in the same layer as the data it describes.
OS & Networkinghttp-debugging