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.

Bounding the Pending State with AbortController

A spinner that never resolves is worse than no spinner at all: it signals “working” indefinitely while the user has no way to know the request has stalled. A dropped connection, a proxy that holds the socket open, or a serverless cold start can all leave a fetch() promise pending far longer than any user will tolerate. Bound the wait explicitly with an AbortController and a timer so the pending state always settles, and always settles into a state the user can act on.

async function submitWithTimeout(data: FormData, ms = 10_000): Promise<void> {
  const controller = new AbortController();
  // Abort the in-flight request once the deadline passes.
  const timer = setTimeout(() => controller.abort(), ms);

  try {
    const res = await fetch('/api/contact', {
      method: 'POST',
      body: data,
      signal: controller.signal, // wiring the controller into fetch
    });
    if (!res.ok) throw new Error(`Request failed: ${res.status}`);
  } finally {
    // Clear the timer whether the request resolved, rejected, or aborted,
    // so a settled request never fires a stray abort later.
    clearTimeout(timer);
  }
}

When the deadline fires, fetch() rejects with a DOMException whose name is "AbortError", and control lands in the same catch branch that restores the form. Distinguish a timeout from a genuine server failure so the announced message is accurate — a timeout is retryable, a 400 usually is not:

try {
  await submitWithTimeout(new FormData(form));
  setPending(false, 'Message sent successfully.');
  form.reset();
} catch (err) {
  const timedOut = err instanceof DOMException && err.name === 'AbortError';
  setPending(false, timedOut
    ? 'The request timed out. Check your connection and try again.'
    : 'Something went wrong. Please try again.');
  button.focus();
}

Because the timeout path resolves through the existing catch, no new exit branch is introduced — the single-restore invariant from the minimal implementation still holds. Keep the deadline generous enough to survive a slow-but-working mobile connection (8–15 seconds is a common band) and pair it with a server-side idempotency key when a POST could still land after the client has given up.

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.

How long should I wait before timing out the request?

There is no value in the spec — fetch() has no built-in deadline — so you choose one with an AbortController. A window of roughly 8–15 seconds tolerates a slow mobile uplink or a serverless cold start without leaving the spinner running forever. Treat the resulting AbortError as retryable and announce it distinctly from a server-side rejection, and add a server idempotency key so a request that lands after the client aborts does not create a duplicate.

← Back to Form Submission Lifecycle