The exact problem: a reader presses submit, the button is replaced by a spinner, the request takes two seconds, and focus is on the document body — so when the response arrives, nothing is announced and the next Tab starts from the top of the page.

Context and Prerequisites

Where focus goes after a validation failure is covered in focus management after validation. This page is about the gap the async submit opens: the period between pressing submit and the response, during which the element that had focus can disappear.

The Rule: Never Let the Focused Element Vanish

async function onSubmit(e: SubmitEvent, form: HTMLFormElement): Promise<void> {
  e.preventDefault();
  const submitter = (e.submitter as HTMLButtonElement | null) ?? form.querySelector('[type=submit]');

  // Disable, do NOT remove or replace. A disabled button keeps its place in the
  // DOM and keeps focus; replacing it with a spinner drops focus to <body>.
  if (submitter) {
    submitter.disabled = true;
    submitter.setAttribute('aria-busy', 'true');
  }
  announce('Submitting…', 'polite');

  try {
    const res = await send(new FormData(form, submitter ?? undefined));
    if (res.ok) {
      // Success moves focus deliberately, to the confirmation heading, which is
      // where the reader's next task begins.
      focusConfirmation();
      return;
    }
    const errors = await mapServerErrors(res);
    renderErrors(errors);
    // Failure: two or more errors go to the summary; one goes to its field.
    Object.keys(errors).length > 1 ? focusSummary() : focusField(Object.keys(errors)[0]);
  } finally {
    if (submitter) {
      submitter.disabled = false;
      submitter.removeAttribute('aria-busy');
    }
  }
}

The single most effective rule is in the first branch: disable, do not replace. A disabled button remains in the document and, in current browsers, retains focus — so when the response arrives the reader is still somewhere sensible, and a focus move to the summary or the confirmation is a move from a known place rather than from nowhere.

Replacing the button loses the reader; disabling it does not Top timeline: the reader presses submit, the button is unmounted and replaced by a spinner element, focus falls to the document body because the focused element no longer exists, the response arrives and is announced into a live region the reader may not be listening to, and the next Tab press restarts from the top of the page. Bottom timeline: the reader presses submit, the same button is disabled in place and marked busy, focus stays on it throughout, the response arrives and focus is moved deliberately to the confirmation heading or the error summary, and the reader continues from there. replaced by a spinner submit pressed focus on the button button unmounted focus falls to body response arrives announced into nowhere Tab restarts from the page top disabled in place submit pressed focus on the button disabled + aria-busy still in the document response arrives focus moved on purpose reader continues from a known place If the button genuinely must be replaced, record document.activeElement first and restore it before moving focus deliberately.

Step-by-Step Walkthrough

  1. Disable, do not replace. The busy state is an attribute, not a different element.

  2. Announce the wait politely. “Submitting…” in the status region, so a reader who cannot see the spinner knows one exists.

  3. Record the focused element anyway. Cheap insurance if a re-render replaces it despite your intentions.

  4. Move focus deliberately on the response. Confirmation heading on success; summary or field on failure.

  5. Re-enable in a finally. A thrown error that leaves the button disabled is a form the reader cannot retry.

  6. Do not move focus for a background save. An autosave completing is not a reason to take the reader’s cursor.

Where focus goes for each response, and why A success that stays on the page focuses the confirmation heading, because that is where the reader's next task begins. A success that navigates focuses the destination heading, since a client-side navigation moves nothing by itself. A failure with several field errors focuses the summary, which gives scope before detail. A failure with one field error focuses that field, because a summary of one is a detour. A failure with no field to blame focuses the form-level message, which is the only thing that can explain it. Response Focus goes to Because success, staying the confirmation heading the next task starts there success, navigating the destination heading navigation moves nothing itself failure, 2+ fields the error summary scope before detail failure, 1 field that field a summary of one is a detour failure, no field the form-level message nothing else can explain it Every row moves focus somewhere. None of them leaves it on the submit button, and none leaves it on the body.

Failure Modes and Edge Cases

1. A framework re-render replaces the button anyway

Keying the button on the submitting state remounts it. Keep the key stable and change only the attributes.

2. Focus moved before the target exists

Rendering the summary and focusing it in the same frame works only if the render is synchronous. Await the render, then focus — and check the element is in the document.

3. The reader moved during the request

Someone who tabbed away and started typing elsewhere should not be yanked back for a success. For a failure, moving focus is still right — they need to fix something — but announce it rather than moving silently.

4. Navigating away on success

If success means a route change, the destination must take focus. A client-side navigation does not move focus by itself, so the reader lands on a new page with focus still on the old one.

5. Double submission via keyboard repeat

Holding Enter on a focused submit button repeats the keydown. The disabled attribute covers it, which is another reason to disable rather than replace.

The busy state has an accessible shape as well as a visual one, and the two are easy to get out of step:

What the busy state must say, and to whom Visually the reader needs the control to look unavailable and something to indicate work is happening, and the layout must not shift when it appears. For assistive technology the control needs aria-busy on the form or the region, the disabled state on the button itself, and a polite announcement that submission has started. The mistake is providing only the visual half, which leaves a reader who cannot see the spinner with a button that silently stopped responding. visually the control looks unavailable something indicates work is happening the layout does not shift and it is not the only signal for assistive technology aria-busy on the form or region disabled on the button itself a polite "Submitting…" announcement so the pause is explained, not just felt A spinner alone tells a screen reader user nothing: the button simply stopped responding and no one said why.

Verification Checklist

Common Pitfalls

  • Keying the submit button on the submitting state. A framework that sees a different key remounts the element, which is the same failure as replacing it by hand. Keep the key stable and change only the attributes.
  • Focusing the summary in the same tick it is created. The element has to be in the document before focus() can reach it. Insert, then focus, and check the node is connected before calling.
  • Leaving the button disabled after a thrown error. A catch that does not re-enable produces a form the reader cannot retry, which is worse than the original failure. Re-enable in a finally.
  • Announcing the result only visually. A spinner that stops and a message that appears are both invisible to a screen reader unless something announces them. The focus move covers the summary; the polite region covers the wait.
  • Moving focus for a background save. An autosave completing is not a reason to take the cursor away from whatever the reader is doing. Announce it politely and leave focus alone.

Related

Focus Management After Validation

Frequently Asked Questions

Why does replacing the button with a spinner break focus?

Because focus lives on an element, and removing that element from the document leaves the browser with nowhere to put it — so it falls back to the body. From there nothing is announced when the response arrives, and the next Tab starts from the top of the page. Disabling the button instead keeps the element, keeps focus, and gives you a known place to move focus from when the response comes back.

Should focus move if the reader has already moved on?

For a failure, yes — they need to repair something, and leaving them where they are means the failure may never be noticed. Announce it as well, so the move is explained rather than surprising. For a success, no: taking the cursor away from whatever they started doing is disruptive and gains nothing, so announce it politely and leave focus alone.

What about a form that navigates on success?

The destination has to take focus, because a client-side navigation does not move it. Without that, the reader arrives on a confirmation page with focus still on the button of a form that no longer exists, and a screen reader announces nothing about where they are. Focus the destination’s main heading as part of the navigation, exactly as a multi-step wizard focuses each new step.