Handling 422 Unprocessable Content Responses

What should a form do when fetch resolves — not rejects — with a 422 Unprocessable Content? Or a 400, 409, 413, 429 or 503? Many submission handlers only distinguish “ok” from “not ok”, show a generic banner for everything else, and leave the submit button spinning or disabled. This recipe writes one submission function that branches on status, treats 422 as “apply these field errors”, gives every other failure its own recovery, restores the form’s state in all cases, and routes field errors through setCustomValidity() and reportValidity() so the Constraint Validation API handles focus and announcement exactly as it does for client-side failures.

When to Use Status-Aware Submission Handling

Use it for every form submitted with fetch — which, with progressive enhancement, is every form whose default no-script path is a normal post. It matters most when:

  • The server enforces rules the client cannot — uniqueness, stock, permissions — so 422s are routine, not exceptional.
  • Submissions can be retried — payments, bookings — where the difference between “invalid” and “temporarily failed” decides whether retrying is safe.
  • Several failure modes need different UI: field errors, a sign-in prompt, a conflict resolution, a wait-and-retry message.

The response body format assumed here is the contract from API validation error contracts. 422 is the status that contract reserves for field-level rule failures — “the request was understood and well-formed, but its content breaks the rules” — which is why it is the one status that maps directly onto inputs.

Status codes a form submission must handle A table of HTTP status codes with their meaning for a form submission, whether retrying is safe, and what the form should do. Meaning Retry? Form action 422 rules broken after edit field errors + focus 400 malformed request ✗ No generic error, report bug 401 / 403 ✗ t signed in / allowed after sign-in preserve input, prompt 409 conflict after reload show latest, explain 413 too large after edit error on file field 429 / 503 busy or limited ✓ Retry-After wait message, retry
Only 422 maps onto fields; every other failure needs its own recovery, and only some are safe to retry automatically.

Minimal Working Status-Aware Submitter

type Outcome =
  | { kind: "ok"; response: Response }
  | { kind: "invalid" }          // 422: errors applied to fields
  | { kind: "auth" }             // 401 / 403
  | { kind: "conflict" }         // 409 / 412
  | { kind: "retry-later"; seconds: number }
  | { kind: "failed" };

export async function submitForm(form: HTMLFormElement, signal?: AbortSignal): Promise<Outcome> {
  // Client-side gate first: the canonical novalidate + reportValidity() baseline.
  if (!form.reportValidity()) return { kind: "invalid" };

  const button = form.querySelector<HTMLButtonElement>("[type=submit]");
  button?.setAttribute("aria-disabled", "true");
  form.setAttribute("aria-busy", "true");

  try {
    let res: Response;
    try {
      res = await fetch(form.action, {
        method: form.method || "POST",
        body: new FormData(form),
        headers: { accept: "application/json, application/problem+json" },
        signal: signal ?? AbortSignal.timeout(20_000),
      });
    } catch {
      showFormError(form, "We couldn't reach the server. Check your connection and try again.");
      return { kind: "failed" };                    // network error or timeout: fetch rejects only here
    }

    if (res.ok) return { kind: "ok", response: res };

    switch (res.status) {
      case 422: {
        const body = await res.json().catch(() => null);
        const errors = toFieldErrors(body);          // problem details or { errors } → { name: messages[] }
        if (errors) {
          applyServerErrors(form, errors);           // setCustomValidity + reportValidity
          return { kind: "invalid" };
        }
        showFormError(form, "Some details need correcting, but we couldn't tell which. Please review the form.");
        return { kind: "invalid" };
      }
      case 401:
      case 403:
        showFormError(form, "Your session has ended. Sign in again — your answers are kept.");
        saveDraft(form);
        return { kind: "auth" };
      case 409:
      case 412:
        showFormError(form, "This was changed by someone else while you were editing. Review the latest version.");
        return { kind: "conflict" };
      case 413:
        applyServerErrors(form, { [fileFieldName(form)]: ["The file is too large to upload."] });
        return { kind: "invalid" };
      case 429:
      case 503: {
        const seconds = Number(res.headers.get("retry-after")) || 30;
        showFormError(form, `We're busy right now. Please try again in ${seconds} seconds.`);
        return { kind: "retry-later", seconds };
      }
      default:
        showFormError(form, "Something went wrong on our side. Please try again.");
        return { kind: "failed" };
    }
  } finally {
    button?.removeAttribute("aria-disabled");
    form.removeAttribute("aria-busy");
  }
}

function toFieldErrors(body: any): Record<string, string[]> | null {
  if (body && Array.isArray(body.errors)) {
    // RFC 9457-style: [{ pointer: "/address/city", detail: "…" }]
    return body.errors.reduce((acc: Record<string, string[]>, e: any) => {
      const name = String(e.pointer ?? "").slice(1).split("/").join(".");
      (acc[name] ??= []).push(String(e.detail ?? e.message ?? ""));
      return acc;
    }, {});
  }
  if (body && body.errors && typeof body.errors === "object") return body.errors;   // { name: [msgs] }
  return null;
}

The first and most common bug this avoids is assuming fetch rejects on HTTP errors. It does not: it only rejects on network failure, abort or timeout. Every 4xx and 5xx resolves, so code that does try { await fetch(...) } catch { showError() } silently treats a 422 as success.

Submission outcomes and where they lead From submitting, the form moves to success, to field errors for 422, to a sign-in prompt for 401 or 403, to a conflict review for 409, or to a wait-and-retry state for 429 and 503. submitting success field errors sign in again retry later 2xx 422 / 413 edit + submit 401 / 403 429 / 503 / network after Retry-After
Each outcome has its own recovery, and every path returns the submit button to a usable state.

Submission Handler Option Reference

Option Type Default Purpose
accept header string JSON + problem JSON Server returns structured errors, not HTML
Timeout AbortSignal.timeout 20 s Bounds hung requests so the form recovers
Busy state aria-busy + aria-disabled on Signals progress without removing the button from focus order
422 body parser toFieldErrors problem details or map Accepts both common error shapes
Retry-After header seconds 30 fallback Honours server back-off for 429 and 503
Draft saving saveDraft on 401/403 Preserves input across re-authentication

Using aria-disabled instead of the disabled attribute on the submit button during submission keeps the button focusable and announced, which matters because the browser moves focus away from a disabled button, stranding keyboard users. The broader loading-state pattern is covered in showing a loading state during form submission.

Verification Steps

import { test, expect } from "@playwright/test";

test("422 without a body still leaves the form usable", async ({ page }) => {
  await page.route("**/api/profile", (r) => r.fulfill({ status: 422, body: "" }));
  await page.goto("/profile");
  await page.getByRole("button", { name: "Save" }).click();
  await expect(page.getByRole("alert")).toContainText("Some details need correcting");
  await expect(page.getByRole("button", { name: "Save" })).not.toHaveAttribute("aria-disabled", "true");
});

Edge Cases and Failure Modes

Automatic retries of 422. Retrying an invalid request produces the same 422. Only 429, 503 and network failures are candidates for automatic retry, and only for idempotent operations or requests carrying an idempotency key. A retried payment without one can charge twice.

HTML error pages with a 422. Some frameworks render an HTML page for validation errors unless asked for JSON. The accept header requests JSON; the .catch(() => null) on res.json() and the empty-body fallback keep the form working if the server ignores it.

Losing input on 401. Redirecting to a sign-in page without saving the draft throws away everything the user typed. Save to sessionStorage (excluding passwords and card data) and restore after sign-in.

Double submission while a 422 is in flight. A user who clicks twice can receive two 422s out of order. Ignore responses for superseded submissions, following preventing double form submission.

Announcing the Outcome Once

Every branch in the submitter ends with the user needing to know what happened, and each needs exactly one announcement. For 422, reportValidity() does it: focus moves to the first failing field and its message is read. For every form-level outcome — a sign-in prompt, a conflict, a wait-and-retry message — render the message into a single role="alert" region at the top of the form, replacing its previous content, so a user who submits three times hears three messages rather than a growing list. Success deserves the same care: announce “Saved” through a polite status region, or move focus to the confirmation heading if the page changes, as described in form submission success confirmation patterns. Avoid combining a toast with an inline message for the same event; screen reader users hear both, and sighted users see the toast vanish before they have read it.

Choosing Between 400 and 422 on the Server

The client code above relies on the server using 422 consistently for rule failures, so it is worth being precise about the boundary. 400 Bad Request means the server could not make sense of the request: invalid JSON, a missing multipart boundary, a field that should be an object arriving as a string. The user cannot fix any of these by editing the form — they are bugs in the client or tampering. 422 Unprocessable Content means the request was perfectly readable and its values break rules: an email already registered, a quantity above stock, a date in the past. Those map onto fields. A schema failure is usually a 422, because the body parsed fine and a field value failed a rule; only a failure to parse the body at all is a 400. Keeping that split lets clients handle 422 generically and treat 400 as “report this to engineering”, which is exactly how the submitter above behaves. The shape of the 422 body is defined in problem details (RFC 9457) for field errors.

Server choice between 400 and 422 A decision tree for the server: if the request body cannot be parsed return 400, if it parses but values break rules return 422 with field errors, otherwise continue processing. Could the body be parsed? no 400 Bad Request, form-level yes Do values break rules? yes 422 with pointers per field no Continue: auth, conflicts, save
Parse failures are 400 and are never shown as field errors; value failures are 422 and always point at fields.

Frequently Asked Questions

Does fetch throw on a 422 response?

No. fetch only rejects on network errors, aborts and timeouts. Any HTTP status, including 4xx and 5xx, resolves normally, so check response.status or response.ok yourself.

What is the difference between 400 and 422 for form validation?

400 means the request could not be parsed at all. 422 means it was understood but its values break rules, such as a duplicate email. Only 422 errors map onto form fields.

Should a form automatically retry after a 422?

No. The same values will fail again. Apply the errors and wait for the user to edit. Only retry automatically for 429, 503 or network failures on idempotent requests.

How do I keep the submit button usable during and after submission?

Use aria-disabled and aria-busy rather than the disabled attribute while the request is in flight, and remove them in a finally block so every outcome, including errors, restores the button.

← Back to API Validation Error Contracts