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 thandisabled, 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.
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.
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 |
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
isRetryingin state and render the same<button>element in both states, changing only its text andaria-disabled. Rendering a different element (or a spinner instead of the button) unmounts the focused node. TanStack Query’srefetch()returns a promise you can await for the outcome message. - Vue — bind
:aria-disabled="retrying"and guard in the handler; avoidv-ifswaps between “Try again” and “Retrying” buttons for the same reason. - Angular —
[attr.aria-disabled]="retrying || null"removes the attribute when false; the CDKLiveAnnouncercan 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.
Related
Permalink to "Related"- Announcing data load errors — the failure message
- Stale and partial data — after a partial recovery
- Preserving focus when rows are deleted — the same focus problem in another form