DataGENERALPLATFORM-SPECIFICFRAMEWORK-SPECIFIC

Loading, Error, Empty — The States You Did Not Render

An interaction has at least four states and usually five. The missing failure branch is the most common defect in frontend code, and empty is not loading.

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

How many states does a piece of remote data actually have, and which of them is my interface silently missing?

The user intent

A person clicks something and wants to know one of three things at every moment: is it working, did it work, or did it not. An interface that answers none of those leaves them clicking again.

The obvious build

A boolean. isLoading is true while the request is out and false when it comes back; render a spinner in one case and the data in the other. It reads well and it matches how the request feels while you are writing it.

Why it breaks

The boolean has no room for failure. When the request rejects, isLoading becomes false and data stays empty, so the screen renders the success branch over nothing — usually a blank panel with no explanation.

How it breaks in a real browser
  • The boolean has no room for failure. When the request rejects, isLoading becomes false and data stays empty, so the screen renders the success branch over nothing — usually a blank panel with no explanation.
  • It cannot distinguish "we have not asked yet" from "we asked and there is nothing". A filter that matches no rows and a request that has not started look identical, and one of them needs a message about the filter.
  • It cannot express stale: showing the data you already have while a background refresh runs. With a boolean you must either hide the data behind a spinner or hide the fact that a refresh is happening (Stale-While-Revalidate).
  • Two independent booleans — isLoading and hasError — permit states that cannot exist. loading && error renders both, and nobody wrote what that should look like.
  • Nothing here is announced. Every state change in this list is visual only, so a screen-reader user experiences a page that silently changes underneath them (Live Regions and Announcement).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A remote value is a state machine, not a variable. idle → pending → success | error, with success → pending for a refresh and a stale flag that says the displayed data is real but no longer known to be current.
  • Empty is a success, not a third loading state. The request completed, the answer was zero rows, and that answer is information the user needs — often about the filter they applied rather than about the data (Filtering: An Allowlist With an Index Bill in API Design).
  • The machine has one input the naive version lacks: the *reason* for the error. Offline, unauthorised, forbidden, not found, rate-limited and server-broken are six different messages and three different recovery actions (The Error Model: Structure Over Apology in API Design).
  • Rendering a skeleton is a claim about the shape of the answer. If the skeleton shows six rows and the answer has one, the layout moves after the data arrives, and the user watched a lie for a second (Visual Stability).
  • Every transition in the machine is an event a screen reader can be told about — but only through a live region that already existed in the DOM before the change happened.

What this makes the browser do

And which of it is avoidable.

  • Skeletons are real DOM. Fifty skeleton rows are fifty elements to build, style, lay out and paint — then throw away and do again with the real data (What a Mutation Costs).
  • A shimmer animation that animates background-position repaints on every frame for the whole duration of the request; one that animates transform on a masked layer usually does not (Cheap and Expensive Animation).
  • Swapping a skeleton for content of a different height forces layout for everything below it, which is why the reserved space needs to be the size of the answer rather than a pleasant default (Visual Stability).
  • Avoidable: rebuilding the entire region on every state change. Keeping a stable container and swapping only its contents preserves scroll position and the accessibility tree around it.

Four states, and the fifth you have already shipped

Written out as a table, the gap in the boolean version is immediate. Each row is a distinct thing the user needs to know, a distinct thing assistive technology needs to be told, and a distinct bug that appears when the row is missing.

The stale row is the one that surprises people. It is not a loading state and not an error: it is real data, previously correct, now being re-checked. Every application with a background refresh has it whether or not anyone modelled it, and modelling it is what lets you keep content on screen during a refetch instead of blanking the page (Stale-While-Revalidate).

  • One status field, not three booleans — the impossible combinations should be unrepresentable, not merely unlikely.
  • error carries a *classification*, not just a flag. Offline, unauthorised, forbidden, not found, rate-limited and server-error are different messages and different recovery actions.
  • empty belongs to success. It means the request worked and the answer was nothing.
StateWhat the user must seeWhat must be announcedThe bug when it is missing
idleThe control that starts it, and nothing pretending to be dataNothing — silence is correct hereAn empty panel on first paint that looks like a failed load
pendingA busy indicator with a name, after a short delay"Loading orders" via role="status"The user clicks again, and now two requests are in flight
successThe data, in a stable containerOptionally the result count — "12 orders"None; this is the branch that always works
emptyA sentence saying what is absent and why"No orders match this filter"Indistinguishable from a broken load; the user blames the app for their own filter
errorThe reason, and an action that could fix itThe message via role="alert", with focus placed usefullyA blank screen, or a render that throws reading a property of undefined
staleThe previous data, plus a quiet refreshing hintNothing, unless the content actually changedContent vanishes behind a skeleton on every poll, and the reading position is lost

Announced, not just rendered

PLATFORM-SPECIFICThe pre-existing-region rule is a consequence of how screen readers observe accessibility-tree mutations, and the details differ by product: some announce a region inserted with content, most do not, and the behaviour has changed between versions of the same screen reader. Building the region up front is the option that works everywhere.

A spinner is a visual convention. It carries no name, no role and no text, so for a screen-reader user the page simply goes quiet and then, some seconds later, contains different things. An error rendered as a red border is worse: it is a change with no announcement and no accessible description, and the user has no way to know a change happened at all.

The fix is small and specific. A container with role="status" that already exists in the DOM, whose *text* changes as the machine moves. The region must pre-exist — a live region created at the same moment as its content is unreliably announced, which is the single most common reason a correct-looking implementation is silent.

accessibility specAsynchronous data region (idle → pending → success | empty | error)An asynchronous region, specified

semantics A stable container that is present from first render. Inside it, a role="status" element for progress and result counts and a role="alert" element for failures. The data itself keeps its own semantics — a table, a list — and is never wrapped in a role that describes the request rather than the content.

TabReaches the retry or the recovery action when the region is in error. Nothing in pending should steal focus, and nothing focusable should be removed while it holds focus.
Enter / SpaceActivates retry from a real button, which is why it must be a button and not a clickable div (Semantics Are Behaviour).
Shift + TabReturns to the control that triggered the request. It must still exist — replacing the whole region on failure destroys the user's place in the page.
Focus
  • Never remove the focused element to show a spinner. If the trigger must become busy, keep it focusable and describe the state rather than disabling it out of the tab order.
  • On a failure that followed a deliberate submission, move focus to the error message or to the recovery action. On a failure of a background refresh, do not move focus at all — the user did not ask for anything.
  • After a successful load that replaces the region's contents, leave focus where it was. Moving it "to the new content" interrupts anyone who was already somewhere.
Announces
  • pending: a short, specific phrase — "Loading orders" — not "Loading".
  • success: nothing, or a result count if the count is what the user was after. Announcing every successful refresh of a polling dashboard is a form of harassment.
  • empty: the reason, including the filter if a filter caused it.
  • error: the classification in plain language plus the action available, through role="alert".

usually broken by The pattern invites two failures that both look correct in the markup. The first is creating the live region and its message in the same update, so there is no change *inside* an existing region for assistive technology to observe and nothing is announced. The second is setting aria-busy="true" on the container while loading and never clearing it, which suppresses the announcement of the result as well — a page that is permanently, silently busy (Live Regions and Announcement).

Skeletons that lie

A skeleton is a promise about the answer: this many rows, this tall, in this arrangement. When the promise is kept it is genuinely better than a spinner, because the layout is already correct when the data lands and nothing moves. When it is not kept, it is a layout shift dressed as a courtesy — and the user was reading the position where content was about to appear.

The honest version reserves space for the *container*, not for imaginary content, whenever the shape of the answer is unknown. It also refuses to promise a count it does not have: six shimmering rows followed by "No orders yet" is the worst outcome in this lesson, because it shows a person their data and then takes it away.

Two skeletons for the same list
A skeleton that guesses
{isLoading && (
  <>
    {Array.from({ length: 6 }).map((_, i) => (
      <div key={i} className="skeleton-row" />   // 6 rows, always
    ))}
  </>
)}
{!isLoading && <OrderList orders={orders} />}
// answer has 1 row  -> everything below jumps up five rows
// answer has 0 rows -> the user was shown six orders that do not exist
// answer is an error -> nothing at all is rendered
A skeleton that reserves
<div className="orders" style={{ minBlockSize: 'var(--orders-reserved)' }}>
  {status === 'pending' && (
    <p role="status">Loading orders…</p>
  )}
  {status === 'success' && orders.length > 0 && <OrderList orders={orders} />}
  {status === 'success' && orders.length === 0 && (
    <p>No orders match this filter. <button onClick={clearFilters}>Clear filters</button></p>
  )}
  {status === 'error' && (
    <p role="alert">{message(error)} <button onClick={retry}>Try again</button></p>
  )}
</div>
// the container reserves space; the contents never promise a count

The reserved container removes the layout shift without asserting anything false about the answer, and all four outcomes render inside one stable element — so the live region survives every transition and the surrounding page geometry never moves. The guessing version optimises the one case where the guess is right and regresses the other three (Visual Stability).

How to build it

Most important first.

  • Make the states a single discriminated value, so the impossible combinations cannot be represented. One status field beats three booleans for exactly the same reason a tagged union beats a bag of optional fields.
  • Write the error branch first, before the success branch. It is the branch that will not otherwise be written, and writing it first forces the question of what the user can actually *do* about each failure.
  • Distinguish empty from loading from error visually and in text. "No orders yet" and "Could not load orders" and a spinner are three different screens, and a user who cannot tell them apart cannot tell you what went wrong.
  • Give every failure a recovery action in reach: a retry button for a transient failure, a sign-in link for an expired session, a "clear filters" for an empty result caused by the filter (Retries, and the Duplicate Order).
  • Prefer showing stale data with a quiet refreshing indicator over replacing it with a skeleton. A person mid-sentence in a table does not want their content to vanish because a poll fired (Stale-While-Revalidate).
  • Announce transitions through a live region that is present in the DOM from the start, and reserve layout space so the announcement does not arrive with a jump (Live Regions and Announcement).

Keyboard, focus, semantics, announcement

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

  • Loading must be announced. role="status" (an aria-live="polite" region) containing the words "Loading orders" is announced when it appears; an animated div with no text is not announced at all.
  • Errors must be announced more assertively and must be reachable. role="alert" interrupts; the message must also sit next to the control the user was operating, and focus should move to it or to the retry action when the failure was the result of a deliberate submission (Errors People Can Actually Perceive).
  • Empty is content, not absence. "No results for 'refund'" is announced; an empty container is silence indistinguishable from the loading state.
  • Do not mark the container aria-busy="true" and leave it that way. Assistive technology suppresses updates inside a busy region, so a forgotten flag means the successful result is never announced either.
  • A disabled retry button removes it from the tab order and from most screen-reader interaction. Prefer keeping it focusable and describing the busy state, so the user is never focused on nothing (Keyboard Operability).

What can go wrong

Failure modes
  • The error state exists but is unreachable in practice — rendered inside a component that only mounts on success. The branch was written and can never run.
  • A generic "Something went wrong" for all six error classes, which converts a solvable user problem (sign in again) into a support ticket.
  • A retry button that re-runs the same request against the same expired session, forever, because the failure was never classified.
  • Skeletons sized for a design mock rather than for real data, so the page reflows once per request and the reflow lands under the user's cursor (Visual Stability).
  • An error toast that disappears on a timer. A screen-reader user, a user who looked away, and a user on a slow device may all miss it entirely.
  • The mitigation failing: a live region added correctly, but the whole region is removed and re-added on each state change, so the browser never sees a change *inside* an existing region and announces nothing.
What can arrive out of order
  • A refetch triggered while an earlier one is still pending can resolve second, moving the machine from success back through pending to a *stale* answer (Out-of-Order Responses).
  • An error from an abandoned request can arrive after a newer request has already succeeded, flipping a good screen into a failure state for a request nobody is waiting for (Cancelling a Request Nobody Is Waiting For).
  • A live region announcement races the DOM change it describes: change the text and the surrounding structure in the same task, and screen readers vary in whether they announce the new text, the old text, or nothing.
Security
  • Error text goes to the user. Server messages that leak stack traces, internal hostnames or SQL fragments are a disclosure, and the client is the wrong place to sanitise them — but it is the place where you notice (Error Handling and Information Leakage in Security).
  • Render error bodies as text. An error message inserted through an HTML sink is a script-injection path from anything that can influence the message (Cross-Site Scripting).
  • Do not distinguish "no such account" from "wrong password" in the UI copy just because the API distinguished them. Enumeration is a real attack and the frontend is where it becomes visible.
  • A failure that reveals whether a resource *exists* to a user who is not allowed to see it is an authorization leak. 404 and 403 are a product decision as much as a status decision (Authorization-Aware UI).
Misreads
  • "Loading and error are enough." Empty and stale are the two that get skipped, and both are common: most filters eventually match nothing and most data is eventually shown after it was fetched.
  • "The error boundary catches it." A framework error boundary catches an exception thrown during render. A rejected promise inside an effect is not that: nothing catches it unless you write the catch yourself.
  • "A red border communicates the error." It does not exist for a screen-reader user, it does not exist for many colour-blind users, and it does not say what to do next.
  • "Skeletons are always better than spinners." A skeleton that does not match the answer is a layout shift with extra steps.
  • "We can add the empty state when it comes up." It comes up on the first real customer with no data — which is every customer on their first day.

Measuring it, and what changes in the field

How you would see this
  • Instrument the states, not just the requests: how often does each screen reach error, and with what classification? A failure rate no one has ever plotted is a failure rate no one believes (Frontend Error Tracking).
  • Track the time spent in pending at the high percentiles from real users. The median is fine everywhere; the tail is where the spinner becomes an abandonment (Tail Latency: Why p50 Being Fine Does Not Help in Observability).
  • Use the accessibility inspector to confirm the live region exists and carries the role you think it does — the failure here is silent by construction (Accessibility Testing).
Slow device, slow network, large data, old tab
  • On a fast connection, pending is over so quickly that a spinner is a flash of noise. On a slow one it is the whole experience, which is why a small delay before showing a spinner and a minimum display time once shown are both worth having.
  • On a slow device, the skeleton itself costs render time, so a heavy skeleton delays the content it is standing in for (List Virtualization).
  • On an unreliable connection, error is not exceptional — it is a routine state that some users see several times a session, which changes how much design it deserves (Offline UX).
  • In a long-lived tab, stale becomes the dominant state: most of what a dashboard shows was fetched minutes ago and is displayed as though it were current (Long-Lived Clients and Version Skew).
What this costs
  • Five states cost more code than one boolean, and most of that code runs rarely. It is genuinely more to write, review and test — and it is the difference between an application that degrades and one that goes blank.
  • Skeletons feel faster and constrain your layout: they only work when you know the shape of the answer in advance, and they make the empty state harder because the user has just been shown a picture of rows that do not exist.
  • Delaying the spinner avoids flashing on fast connections at the cost of a short unresponsive-looking gap on slow ones. There is no setting that is right for both.

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 state machine and the requirement to announce transitions apply to every browser and every framework, because they follow from the fact that a request has more than two outcomes and that visual change alone is not perceivable by everyone.
  • PLATFORM-SPECIFICLive-region behaviour is a property of the screen reader and operating system, not of the browser: NVDA, JAWS and VoiceOver differ in whether a region added at the same time as its content is announced, and in how they queue polite against assertive. Test the combination, never one browser alone.
  • FRAMEWORK-SPECIFICData libraries expose this machine directly — a status plus an isFetching flag for the stale case — while a hand-rolled effect gives you nothing and a server-component render gives you a different set of boundaries entirely (Route Loading Boundaries). The states are the same; who owns them is not.

Where the depth lives

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

Performancetail-latency
Domains that do not exist yet
  • Testing & Reliability Engineering — the failure branch is the branch no manual test exercises, which is why fault injection at the network layer belongs in the test suite rather than in a checklist.