Showing an Accessible Loading State During Async Form Submission
Display an accessible pending state while an asynchronous submit is in flight: disable the submit button, render a spinner, expose aria-busy and an aria-live status so assistive technology announces progress, prevent double submission, and re-enable the form on both success and failure. This recipe covers the full submit lifecycle from click to settled outcome without trapping the user in a permanently disabled form.
A naïve handler that just fires fetch() leaves the button clickable during the round trip, so an impatient user double-submits, and a screen reader user gets no signal that anything is happening at all. The pattern below makes the in-flight state explicit, announced, and reversible.
When to Use This Recipe
Apply this pattern whenever submission triggers network I/O that can take more than ~100ms:
- The submit handler calls
fetch(), a GraphQL mutation, or any promise-returning API. - You need to guarantee a single submission even under rapid double-clicks or Enter-key repeats.
- Assistive-technology users must be told the request is pending, succeeded, or failed.
- You have already gated the form on the canonical
novalidate+reportValidity()baseline from the Form Submission Lifecycle and only want to manage the post-valid async phase.
For purely synchronous submits there is nothing to wait on, so the loading state is noise — skip it.
submitting disables the button and sets aria-busy; both terminal states return the form to a usable idle state.Minimal Working Implementation
The form keeps novalidate so we own the validation gate, then enters the async phase only once the synchronous checks pass. A single boolean guard (inFlight) prevents re-entry, and a try/finally block guarantees the form is always restored to a usable state — even if the network throws.
<form id="contact" novalidate>
<div class="form-field">
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div class="form-field">
<label for="message">Message</label>
<textarea id="message" name="message" required minlength="10"></textarea>
</div>
<button id="submit-btn" type="submit">
<span class="btn-label">Send message</span>
<span class="btn-spinner" aria-hidden="true" hidden></span>
</button>
<!-- Polite live region: announces pending/success/error without stealing focus -->
<p id="submit-status" class="visually-hidden" role="status" aria-live="polite"></p>
</form>
const form = document.querySelector<HTMLFormElement>('#contact')!;
const button = document.querySelector<HTMLButtonElement>('#submit-btn')!;
const label = button.querySelector<HTMLSpanElement>('.btn-label')!;
const spinner = button.querySelector<HTMLSpanElement>('.btn-spinner')!;
const status = document.querySelector<HTMLParagraphElement>('#submit-status')!;
// Single source of truth for the in-flight guard.
let inFlight = false;
function setPending(pending: boolean, message = ''): void {
inFlight = pending;
button.disabled = pending; // blocks clicks AND Enter re-submits
button.setAttribute('aria-busy', String(pending));
spinner.toggleAttribute('hidden', !pending);
label.textContent = pending ? 'Sending…' : 'Send message';
if (message) status.textContent = message; // announced via aria-live="polite"
}
async function submitForm(data: FormData): Promise<void> {
const res = await fetch('/api/contact', { method: 'POST', body: data });
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
// 1. Guard against concurrent submissions (double-click, Enter spam).
if (inFlight) return;
// 2. Synchronous validation gate (canonical baseline).
if (!form.checkValidity()) {
form.reportValidity(); // native UI only on explicit submit
return;
}
// 3. Enter the pending state and announce it.
setPending(true, 'Sending your message, please wait.');
try {
await submitForm(new FormData(form));
// Success: announce, then keep the button restored or navigate away.
setPending(false, 'Message sent successfully.');
form.reset();
} catch (err) {
// Failure: re-enable so the user can retry, and announce the error.
setPending(false, 'Something went wrong. Please try again.');
button.focus(); // return focus to the retry affordance
}
});
The try/finally-style symmetry here is deliberate: every exit path calls setPending(false, …), so there is no branch that can leave the button permanently disabled. The synchronous gate reuses checkValidity() + reportValidity() exactly as the Form Submission Lifecycle prescribes, and the async phase begins only after the form is known valid. If your submit also performs a server-side availability or uniqueness check, route that through the same pending state described in Asynchronous Server Checks.
A purely CSS spinner keeps the markup lightweight and respects motion preferences:
.btn-spinner {
display: inline-block;
width: 1em;
height: 1em;
margin-inline-start: 0.5rem;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: btn-spin 0.7s linear infinite;
}
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
margin: -1px; padding: 0; border: 0;
clip: rect(0 0 0 0); clip-path: inset(50%);
overflow: hidden; white-space: nowrap;
}
@keyframes btn-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.btn-spinner { animation: none; }
}
Option Reference
| Option / signal | Purpose | Recommended value |
|---|---|---|
button.disabled |
Blocks clicks and Enter-key re-submits during flight | true while pending |
aria-busy |
Tells AT the region is updating | Set on the button (or form) during flight |
role="status" + aria-live="polite" |
Announces pending/success/error without interrupting | Polite for routine submits |
aria-live="assertive" |
Interrupts for critical failures | Use sparingly, errors only |
inFlight guard |
Idempotency against concurrent submits | Reset in every exit path |
| Spinner visibility | Visual pending cue | Toggle hidden; CSS-only animation |
| Focus on error | Returns the user to the retry control | button.focus() after failure |
prefers-reduced-motion |
Honors motion sensitivity | Disable spinner animation |
Prefer aria-busy + a role="status" region over toggling the button text alone, because text changes inside a non-live element are not reliably announced. Disabling the button is what actually enforces single submission; the ARIA wiring is what makes the state perceivable.
Verification
DevTools. Throttle the network (DevTools → Network → “Slow 3G”) and submit. You should see the button disable, the spinner appear, and aria-busy="true" on the button in the Elements panel. In the Accessibility tree, confirm the status node updates to the pending text. Restore the connection and verify the button re-enables.
Playwright. Intercept the request to control timing, then assert both the disabled state mid-flight and the announced outcome:
import { test, expect } from '@playwright/test';
test('shows pending state and prevents double submit', async ({ page }) => {
// Hold the response open so we can observe the in-flight state.
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
await page.route('**/api/contact', async (route) => {
await gate;
await route.fulfill({ status: 200, body: '{}' });
});
await page.goto('/contact');
await page.fill('#email', 'a@b.com');
await page.fill('#message', 'Hello there team');
const button = page.locator('#submit-btn');
await button.click();
// Mid-flight assertions.
await expect(button).toBeDisabled();
await expect(button).toHaveAttribute('aria-busy', 'true');
await expect(page.locator('#submit-status')).toHaveText(/please wait/i);
// A second click while disabled must not fire a second request.
await button.click({ force: true });
release();
await expect(button).toBeEnabled();
await expect(page.locator('#submit-status')).toHaveText(/sent successfully/i);
});
Edge Cases & Failure Modes
1. Network failure leaves the form stuck. If the await rejects and you only restore state in a success branch, the button stays disabled forever. Fix: restore state in catch (and conceptually finally) as shown — every exit path must call setPending(false, …). Pair this with an AbortController timeout so a hung connection eventually settles rather than spinning indefinitely.
2. Race between submit and late validation. If an async field check (e.g. email availability) is still resolving when the user hits submit, you can fire the POST against stale validity. Fix: await any pending field checks before entering the submit flow, and keep a single inFlight guard that also covers those checks. The cancellation and sequencing strategy lives in Asynchronous Server Checks.
3. Focus lost on a disabled button. Disabling the button while it holds focus drops focus to <body> in some engines, stranding keyboard and screen-reader users. Fix: either move focus deliberately (e.g. to the status region or back to the button after re-enabling, as in the handler), or use aria-disabled="true" plus a guarded handler instead of the native disabled when you must retain focus. After an error, calling button.focus() returns the user precisely to the retry affordance.
Frequently Asked Questions
How do I reliably prevent double submission?
Use two layers: an early if (inFlight) return; guard at the top of the submit handler,
and button.disabled = true for the duration of the request. The boolean guard covers the
edge cases where a click slips through before the disabled attribute applies (or where submission is
triggered programmatically), while the disabled button blocks ordinary clicks and Enter-key repeats.
Should the live region be polite or assertive?
Use aria-live="polite" (or role="status") for the routine pending and
success announcements so you don't interrupt whatever the user is doing. Reserve
aria-live="assertive" (or role="alert") for genuine failures that demand
immediate attention, and keep those messages short and actionable.
What happens to the loading state if the request fails?
The pending state must always be reversible. Restore it in the catch branch — re-enable
the button, hide the spinner, and announce a clear error through the live region — so the user can fix
the problem and retry. Returning focus to the button with button.focus() puts keyboard and
screen-reader users right back on the retry control.
Related Guides
- Form Submission Lifecycle — the full lifecycle from
submitevent through the validity gate this recipe extends. - Asynchronous Server Checks — sequencing and cancelling server round-trips that share this pending state.
- Prevent Default Form Submission Without Losing Validation — the
preventDefault()+ manual validation gate that precedes the async phase. - Constraint Validation API Deep Dive — the
checkValidity()/reportValidity()primitives gating the submit.