Announcing Data Load Errors in Grids

Permalink to "Announcing Data Load Errors in Grids"

A grid that fails to load is often indistinguishable from a grid with no data. The request errors, the rows array is empty, and the component renders its empty state — “No invoices yet”. A screen reader user hears nothing at all, or hears a false statement, and there is no visual clue strong enough for a magnifier user scrolled into the middle of the table. The fix is to treat failure as a first-class state with its own message, its own announcement and its own recovery action.

This guide belongs to empty, error & no-results states.

Spec reference

Permalink to "Spec reference"
  • SC 4.1.3 Status Messages (AA) — the failure is a status message. Because it reports an error the user must act on, role="alert" (assertive) is appropriate, unlike counts and confirmations.
  • SC 3.3.1 Error Identification (A) — strictly about input errors, but its principle — describe the error in text — is what users need here.
  • SC 1.3.1 Info and Relationships (A) — an error row inside the table keeps the structure and associates the message with the data it replaces.
  • SC 1.4.1 Use of Color (A) — a red banner alone does not communicate failure; the text must.
States a data request can end in Four outcomes of a grid request: loading, then either loaded with rows, loaded empty, or failed; each has a different message and a different live-region politeness. States a data request can end inLoadingaria-busy="true";polite "Loadinginvoices"Loadedrows rendered;polite countEmptymessage row; polite"No invoices yet"Failederror row; assertive"Could not loadinvoices"request resolveszero rowsrequest rejects
Failure and empty must never render the same component.

When to use — and when not to

Permalink to "When to use — and when not to"

Use an assertive announcement for failures that block the user’s task: the initial load failed, a page of results failed, or a refresh failed and the data shown is now stale. These require a decision — retry, wait, or work with old data.

Do not use role="alert" for minor, recoverable background failures that the app will retry on its own within a second or two; announcing every transient network blip trains users to ignore alerts. Log those, retry silently, and announce only if retries are exhausted.

The misapplication to avoid: putting the error in a toast that disappears after five seconds. A disappearing error fails users who are slow to reach it and violates SC 2.2.1 Timing Adjustable in spirit. Keep the error in the grid until it is resolved.

Toast-only error versus in-grid error Comparison: a disappearing toast versus an error row inside the grid that persists until resolved. Toast-only error versus in-grid error✗ Toast onlyDisappears after a few secondsOften not announced at allGrid shows "No data" underneathRetry is in a different place from the data✓ Error row in the gridStays until the data loadsAnnounced once via role="alert"Grid says what failedRetry button inside the error row

Annotated code example

Permalink to "Annotated code example"
<!-- Alert container exists from load; only its text changes (SC 4.1.3) -->
<div id="grid-alert" role="alert" class="visually-hidden"></div>

<table aria-labelledby="inv-caption" aria-busy="false">
  <caption id="inv-caption">Invoices</caption>
  <thead>…</thead>
  <tbody>
    <tr class="error-row">
      <td colspan="4">
        <!-- Icon is decorative; the words carry the meaning (SC 1.4.1) -->
        <svg viewBox="0 0 20 20" aria-hidden="true" focusable="false">…</svg>
        <strong>Could not load invoices.</strong>
        The server did not respond. Data last loaded at 09:42 is not shown.
        <button type="button" id="retry">Try again</button>
      </td>
    </tr>
  </tbody>
</table>
async function load() {
  table.setAttribute('aria-busy', 'true');
  try {
    const rows = await fetchInvoices();
    render(rows.length ? rows : emptyRow());             // empty is a success
  } catch (err) {
    render(errorRow(err));                               // failure is not "empty"
    alertEl.textContent = '';                            // clear so a repeat failure re-announces
    requestAnimationFrame(() => {
      alertEl.textContent = 'Could not load invoices. Try again is available in the table.';
    });
  } finally {
    table.setAttribute('aria-busy', 'false');            // never leave a grid busy after failure
  }
}

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Event Expected announcement AT deviations
Initial load fails “Could not load invoices. Try again is available in the table.” VoiceOver may wait for current speech to finish
Refresh fails while user is reading Same, interrupting current speech NVDA interrupts; JAWS queues briefly
Navigate to the table “Invoices, table, 2 rows” then error cell text —
Second failure after retry Announced again (region cleared first) Without clearing, some readers stay silent
Choosing politeness for load outcomes Matrix of load outcomes — initial failure, refresh failure with stale data, page failure, transient blip retried automatically — against whether to announce, the politeness, and where the message lives. Choosing politeness for load outcomesAnnounce?PolitenessMessage lives inInitial load failedYesAssertiveError row in tableRefresh failed, stale dataYesAssertiveBanner above tableOne page failedYesPolite if focus elsewhereError row on that pageTransient, auto-retriedOnly if retries exhaustedPoliteLog, then banner
Only failures the user must act on are assertive.

Integration context

Permalink to "Integration context"

The retry button needs its own focus and announcement handling, covered in retry controls for failed data loads. When a refresh fails but old rows stay visible, mark them as stale with the techniques in stale and partial data indicators. For grids that load progressively, the loading half of this story is announcing async loading states in data tables.

Gotchas

Permalink to "Gotchas"

aria-busy left true. If an exception skips the code that clears aria-busy, some screen readers will not read the table’s contents at all. Clear it in finally.

Raw error text. “TypeError: Failed to fetch” or an HTTP status code is not a message. Translate failures into what happened from the user’s point of view and what they can do.

Error and empty share a component. A shared <EmptyState> with a “variant” prop tends to drift until both variants say “Nothing here”. Keep them separate components with separate copy.

Framework notes

Permalink to "Framework notes"

In React, libraries like TanStack Query expose isError separately from data; render the error row from isError before checking data.length. In Angular, HttpClient errors flow through catchError — map them to an explicit { state: 'error' } rather than of([]), which silently converts failure into emptiness. In Vue, keep a status ref with 'loading' | 'ready' | 'error' instead of inferring status from the array.

// Angular: never turn an error into an empty array
this.rows$ = this.http.get<Invoice[]>('/api/invoices').pipe(
  map(rows => ({ state: 'ready' as const, rows })),
  catchError(() => of({ state: 'error' as const, rows: [] })),   // explicit failure state
);

Testing checklist

Permalink to "Testing checklist"

Design system notes

Permalink to "Design system notes"

Give the grid an explicit status prop (loading, ready, error) instead of letting it infer state from an empty array, and an errorMessage slot that renders inside the table body. Publish error-copy guidelines alongside the component: say what failed, whether shown data is old, and what the user can do — never a raw exception or HTTP code.

FAQ

Permalink to "FAQ"
Is role="alert" too aggressive for a data load failure?

Not when the failure blocks the task. Users need to know immediately that what they are looking at is missing or wrong. Reserve assertive messages for that, and keep routine updates polite.

Should focus move to the retry button when loading fails?

Usually not. Moving focus on failure is disorienting, especially if the user was working elsewhere on the page. Announce, keep focus, and make the retry button easy to reach. The exception is when the focused element itself was removed by the failure.

How do I make the error visible to magnifier users?

Put it where the data should be — inside the table — and, for refresh failures, add a banner pinned near the toolbar. Magnifier users are usually looking at the table, not at a corner toast.

Permalink to "Related"

← Back to Empty, Error & No-Results States