Retry Controls for Failed Data Loads

Permalink to "Retry Controls for Failed Data Loads"

A retry button looks trivial: call the loader again. The accessibility problems appear in what happens around the click. The button is usually inside the error row, which is replaced as soon as loading restarts — so focus vanishes with it. Users who activate it twice trigger duplicate requests. Success re-renders the table silently, and a second failure produces the same alert text, which many screen readers will not repeat. Each of these leaves a keyboard or screen reader user unsure whether anything happened.

This guide belongs to empty, error & no-results states and assumes the failure itself is announced as described in announcing data load errors in grids.

Spec reference

Permalink to "Spec reference"
  • SC 2.4.3 Focus Order (A) — when the retry button is removed, focus must go somewhere logical rather than to the document body.
  • SC 4.1.2 Name, Role, Value (A) — the button’s name (“Try loading invoices again”) and its busy state must be programmatically available. Use aria-disabled="true" while busy rather than disabled, so it stays focusable.
  • SC 4.1.3 Status Messages (AA) — “Retrying…” and “Invoices loaded” are status messages.
  • SC 2.2.1 Timing Adjustable (A) — if automatic retries count down (“Retrying in 30 seconds”), users should not be forced to act within that time; the countdown is information, not a deadline.
A manual retry, success path Sequence between the user, the retry button, the grid and the status region: the user activates the button; it becomes aria-disabled and busy; the status says retrying; on success the grid renders rows, focus moves to the first row and the status reports the count. A manual retry, success pathUserRetry buttonGridStatusEnteraria-disabled="true", label"Retrying…""Retrying invoices"rows loadfocus to first row"Invoices loaded, 240 rows"
Focus moves only after rows exist to receive it.

When to use — and when not to

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

Provide a manual retry for every user-visible load failure. Even when the app retries automatically, a manual control gives users agency and an obvious keyboard path.

Do not provide retry for failures that cannot succeed on retry — permission errors (403), not-found (404), or validation errors on a saved filter. A “Try again” button that always fails teaches users that retry is pointless. Explain the real problem and offer the right action instead: request access, choose another view, reset the filter.

The common misapplication is a retry that disables itself with the disabled attribute. Disabled buttons are removed from the tab order and, when focused, focus is lost to the body in most browsers.

disabled versus aria-disabled while retrying The left column lists effects of the disabled attribute on a focused retry button; the right lists aria-disabled with a click guard. disabled versus aria-disabled while retrying✗ disabled attributeFocused button loses focusRemoved from tab orderScreen reader hears nothingRe-enabling does not restore focus✓ aria-disabled="true" plus guardFocus stays on the buttonStill reachable; announced as dimmed orunavailableName changes to "Retrying…", state announcedNothing to restore

Annotated code example

Permalink to "Annotated code example"
const retry = document.getElementById('retry');
let inFlight = false;

retry.addEventListener('click', async () => {
  if (inFlight) return;                              // guard instead of disabled
  inFlight = true;
  retry.setAttribute('aria-disabled', 'true');       // stays focusable (SC 4.1.2, 2.4.3)
  retry.textContent = 'Retrying…';                   // name reflects state
  status.textContent = 'Retrying invoices';          // polite (SC 4.1.3)

  try {
    const rows = await fetchInvoices();
    renderRows(rows);                                // error row, and the button, are gone now
    focusFirstRowOrCaption();                        // logical place, not body (SC 2.4.3)
    status.textContent = `Invoices loaded, ${rows.length} rows`;
  } catch {
    retry.removeAttribute('aria-disabled');
    retry.textContent = 'Try again';
    alertEl.textContent = '';                        // clear so the repeat is announced
    requestAnimationFrame(() => {
      alertEl.textContent = 'Still could not load invoices. Try again later or contact support.';
    });
  } finally {
    inFlight = false;
  }
});
/* Visible busy state that does not rely on colour alone (SC 1.4.1) */
button[aria-disabled="true"] { opacity: .65; cursor: progress; }
button[aria-disabled="true"]::before { content: ""; /* spinner */ }

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Event Expected announcement AT deviations
Enter on Try again “Retrying…, button, unavailable” then “Retrying invoices” VoiceOver says “dimmed” for aria-disabled
Enter again while busy Nothing happens; button still focused —
Success Focus to first row: “Invoice INV-1042, row 2” then “Invoices loaded, 240 rows” JAWS may read the row before the status
Second failure “Still could not load invoices…” Without clearing the region, NVDA may stay silent
Automatic retries with backoff Timeline of automatic retries after a failure: immediate retry, 5 seconds, 30 seconds, then stop and wait for the user; only the stop is announced assertively, earlier attempts are shown visually and announced politely once. Automatic retries with backoff0 sLoad failserror row shown; alert announced once+5 sAutomatic retry 1visible "Retrying…" text; not announced+35 sAutomatic retry 2visible text updated; not announced+2 minRetries exhaustedpolite: "Automatic retries stopped. Use Try again when ready."
Silent early retries; one clear message when the app gives up.

Integration context

Permalink to "Integration context"

Retry is part of the failure state described in announcing data load errors in grids. When retry succeeds but some rows still fail, show those as partial results using stale and partial data indicators. For long-running operations that users start themselves — exports, bulk updates — the same busy-button technique appears in progress indicators for long-running exports.

Gotchas

Permalink to "Gotchas"

Focus to the caption. If the table has no focusable rows (a static table), move focus to the caption or table with tabindex="-1" so screen readers start reading from the right place.

Countdowns that announce every second. “Retrying in 29… 28…” in a live region is unusable. Show the countdown visually and announce only the start and the outcome.

Retrying a stale filter. If the request failed because a saved filter is invalid, retry will fail forever. Detect validation errors and offer “Reset filters” instead.

Testing checklist

Permalink to "Testing checklist"

Framework notes

Permalink to "Framework notes"
  • React — keep isRetrying in state and render the same <button> element in both states, changing only its text and aria-disabled. Rendering a different element (or a spinner instead of the button) unmounts the focused node. TanStack Query’s refetch() returns a promise you can await for the outcome message.
  • Vue — bind :aria-disabled="retrying" and guard in the handler; avoid v-if swaps between “Try again” and “Retrying” buttons for the same reason.
  • Angular — [attr.aria-disabled]="retrying || null" removes the attribute when false; the CDK LiveAnnouncer can carry the polite “Retrying” message.
<button type="button" aria-disabled={retrying || undefined}
        onClick={() => !retrying && retry()}>       {/* same element in both states */}
  {retrying ? 'Retrying…' : 'Try loading invoices again'}
</button>

Design system notes

Permalink to "Design system notes"

Publish a RetryButton that implements the guard, the busy label, the aria-disabled styling and the outcome messages, and accepts an async callback. Every product that loads data needs one, and every hand-rolled version eventually uses disabled and loses focus.

FAQ

Permalink to "FAQ"
Why not just remove the retry button while loading?

Because it is focused. Removing the focused element sends focus to the body, and screen reader users lose their place. Keep it, mark it busy, and move focus deliberately once there is somewhere useful to go.

Should success be announced if focus moves to the first row?

Yes, briefly. The row announcement tells users where they are; the status message tells them the load worked and how many rows there are.

How many automatic retries are reasonable?

Two or three with increasing delays, then stop and say so. Endless silent retrying hides the problem; endless announced retrying drowns the user.

Should the retry button live in the error row or in the grid toolbar?

In the error row, next to the message, so users who read the message find the action immediately. A toolbar Refresh button can exist as well, but it should not be the only route: screen reader users who reach the error by reading the table should not have to navigate back up to the toolbar to act on it.

What should happen to rows the user had selected before the failure?

Keep the selection model intact and re-apply it when rows return. Losing a selection of forty rows because one request failed is a serious cost for keyboard users, who built that selection one Space press at a time.

Permalink to "Related"

← Back to Empty, Error & No-Results States