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.

Async submit loading-state machine The form starts idle. On a valid submit it enters submitting, which disables the button, sets aria-busy true and announces a pending status. The request settles to success or error. Success clears busy and announces completion; error re-enables the button and announces the failure. Both return to idle. Submit lifecycle idle submitting disabled · aria-busy success announce ✓ error re-enable ✗ valid submit retry → idle
The form moves idle → submitting → success/error. Entering 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.

← Back to Form Submission Lifecycle