DEV Community

FrozonFreak
FrozonFreak

Posted on Edited on Originally published at thewebhig.hashnode.dev

Did Place order work? Show the pending state (Web HIG tip #16)

Someone taps Place order on a slow connection. Nothing changes on screen. Did the tap register? Should they try again? A missing pending state leaves them guessing. If repeat requests reach an unprotected endpoint, that uncertainty can also become a duplicate order.

Web HIG tip #16 (Quick #45): Server mutations must show pending UI immediately — disable submit, show spinner.

Why it matters

A request in flight is invisible unless the interface makes it visible. Without that signal:

  • People can't tell a slow request from a tap that didn't register, so they tap again
  • Each extra tap can become another order, payment, invite or post
  • Keyboard users can face the same uncertainty; Enter can submit a form, depending on its controls and platform conventions
  • If the page looks frozen, some people reload or leave halfway through

The full HIG covers this in §4.2, Async Mutation & Server Action State Model: forms executing server mutations MUST supply immediate visual feedback without waiting for server response round-trips. Quick #47 adds that critical mutations should be idempotent. §4.5 says important mutations SHOULD use idempotency keys at the product/API layer. That protects the intended effect of an operation when requests repeat; it does not guarantee a single network request.

Before and after

This bare native POST form has no application-specific pending feedback. The browser may show navigation progress, but the button does not explain what is happening:

<form action="https://example.com/orders" method="post" id="checkout">
  <button type="submit">Place order</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Here is a client-side pending-state illustration, not a complete checkout. It keeps native POST navigation and assumes this is the form's only submission handler. It disables the single submit button and guards against later submit events while pending:

<form action="https://example.com/orders" method="post" id="checkout">
  <!-- Order fields and server-side security protections are omitted. -->
  <button type="submit">
    <span id="checkout-spinner" class="spinner" hidden aria-hidden="true"></span>
    <span id="checkout-label">Place order</span>
  </button>
  <p role="status" id="checkout-status"></p>
</form>

<style>
  .spinner:not([hidden]) {
    display: inline-block;
    width: 0.8em;
    height: 0.8em;
    border: 2px solid currentColor;
    border-inline-end-color: transparent;
    border-radius: 50%;
    animation: spin 1s linear infinite;
  }
  @keyframes spin { to { transform: rotate(360deg); } }
  @media (prefers-reduced-motion: reduce) {
    .spinner:not([hidden]) { animation: none; }
  }
</style>

<script>
  const form = document.getElementById('checkout');
  const button = form.querySelector('button[type="submit"]');
  const label = document.getElementById('checkout-label');
  const spinner = document.getElementById('checkout-spinner');
  const status = document.getElementById('checkout-status');
  let submitting = false;

  function resetPending() {
    submitting = false;
    button.disabled = false;
    label.textContent = 'Place order';
    spinner.hidden = true;
    status.textContent = '';
  }

  resetPending();
  form.addEventListener('submit', (event) => {
    if (event.defaultPrevented) return;
    if (submitting) {
      event.preventDefault();
      return;
    }
    submitting = true;
    button.disabled = true;
    label.textContent = 'Placing order…';
    spinner.hidden = false;
    status.textContent = 'Placing your order…';
    // Let this first submission continue as a native POST navigation.
  });

  window.addEventListener('pageshow', (event) => {
    if (event.persisted) resetPending();
  });
</script>
Enter fullscreen mode Exit fullscreen mode

The handler updates the label, spinner and status text synchronously, before waiting for a response. A fast navigation may replace the page before that feedback is painted or announced. role="status" is a polite live region, not a promise that a screen reader will speak before navigation.

The guard covers only later submit events on this page while its flag is set. A direct form.submit() call bypasses that event. Other tabs, reloads and retries remain outside its scope. The pageshow handler resets the UI when this document returns from the back/forward cache; it does not establish whether an order succeeded.

This snippet does not handle a blocked or cancelled navigation, an in-page request failure, or a successful order response. A real flow needs outcome and recovery UI, preserved input, and server-side validation and security protections. Do not drop this handler into a framework-managed form without adapting it to that form's lifecycle.

A hidden input named idempotency_key would not solve duplication by itself. The server must implement deduplication, including concurrent requests, and bind a key to the same intended operation and authorised caller. Reuse that key for retries of that operation, not unrelated new orders; define payload checks and expiry. If a response is lost, do not assume the order failed. Check its outcome before starting another order. Those server and recovery pieces are outside this example.

Do this instead

  • Update pending feedback in the submission handler, without waiting for the network: disable the submit trigger, show an inline pending indicator, and keep a meaningful label such as "Saving…" or "Sending…"
  • Keep the label meaningful. A spinner alone tells people something is happening, not what
  • Show a clear result. For in-page requests, leave pending state when the outcome is known; on a recoverable failure, restore usable controls and preserve the input
  • For native navigation, provide a usable result or error page and a safe route back. A connection failure can leave the outcome unknown: offer a way to check it rather than blindly retrying
  • Don't rely on the disabled button alone. Important mutations need server-side repeat-request protection; a key only helps when the endpoint actually enforces it
  • If your framework exposes submission state, use its documented pending and recovery lifecycle, and test that feedback does not wait for the server response

Quick check for your app

Use a test environment, not a real payment or production invite. Slow down the response and check that the label and pending indicator update without waiting for it. Try repeated clicks and, where supported, Enter submission. Inspect the network, but also verify the server-side result: deliberately repeated requests for the same operation should not create duplicate orders. Test concurrent requests too.

Exercise validation failures, lost responses, recoverable errors, Back navigation and a retry of the same operation. Check keyboard use, reduced motion and screen-reader feedback; for native navigation, inspect the destination page as well as the pending state. Suitable automated browser and server tests can catch these problems. Pair them with manual checks on the browsers and assistive technology your audience uses. A green suite is evidence for what it actually exercised, not proof that every failure path works.


The Web HIG is an open set of guidelines for how the web should behave, not a component library. Design systems define how it looks. The Web HIG defines how it behaves.

Top comments (0)