Lock the control immediately after submit to prevent a second send
Aliases: double submit · submit lock · in-flight disable
What it is
Once a request leaves, the button that fired it must immediately become unclickable—a submit lockout. The lock prevents one intent from being sent twice: two orders, two mails, two charges. It sits after the pressed state and before a result. A spinner is appearance; the lock is the mechanism.
Why it happens
Between the first sample and server confirmation there is a blank that feels like “nothing moved.” A second click in that blank is rational. If the button still takes pointer events, the second click becomes a second request. An idempotent API can swallow some duplicates; checkout, payment, and one-time codes usually cannot. The lock has to happen on the client frame that sends the request, not when HTTP returns: the slower the response, the wider the double-send window. Disabling the pointer is not enough; Enter, Space, and the form’s default submit still fire. The lock is a gate; loading appearance is a caption on the gate. A spinner with no gate is a busy performance that still accepts a second hit.
Studying it
Log request count against a submit API with tunable delay. Vary lock timing: on press, when the spinner appears, when the response arrives. Let people click freely in the blank.
Independent variables: lock timing, network delay, whether keyboard submit is locked too. Dependent variables: requests per intent, double-order rate, reports of “I clicked twice because nothing happened.”
A lab delay fixed at 300 ms often never sees a second click. Stretch delay to 1–3 seconds and allow real impatience to see the cost of an unlocked control.
Where it stops holding
Instant local actions (switching a tab) need no lock. Steppers and game fire that must repeat would be broken by a lock; throttle instead. After failure the control must unlock or it stays dead—that unlock is a different point. Optimistic UI that pretends success can desynchronize the lock from the spinner; do not watch the spinner alone.
Applying it
- Set the button non-interactive in the same call that sends the request, then paint loading. Do not reverse that order.
- Cut pointer, Enter, Space, and form submit together; a disabled look with live listeners is not a lock.
- Still de-dupe on the server; the client lock is not the only line.
- Verify on a 2-second delayed payment test: each intent must produce one request. A second request means the lock was late or missed a channel.