Idempotency key
The mutating form’s
data-hx-disabled-elt is a client courtesy: it cannot help against a
timeout retry, a flaky network resending the request, a
session expiry
replay, or an Enter pressed twice faster than the disable lands. Only
the server can make a submission single-shot, and the mechanism is a
token: one key per rendered form, and a replayed key gets the
original response back.
Also known as: duplicate-submit prevention, exactly-once submit.
Live demo
Section titled “Live demo”Place the order, then press the button again without touching
anything: the original response is replayed — same order number, no
second order, same toast. Change the amount and resubmit to see the
real conflict (422, “already submitted with different values”), and
type a non-number to see a validation failure that leaves the key
live. The endpoint is a namespaced demo implementation under
api/recipes/idempotency-key/ (a real server stores key → response;
the stateless demo writes the spent key back into the form
out-of-band so the form itself carries the “already seen” bit).
Loading form…
The markup
Section titled “The markup”<form method="post" action="/orders"> <input type="hidden" name="idempotency_key" value="ik_7d1f9c2e"> <!-- …fields… --> <button class="hc-button" data-variant="primary" type="submit" data-hx-post="/orders" data-hx-target="#order-result" data-hx-swap="innerHTML" data-hx-disabled-elt="this">Place order</button></form>
<div id="order-result" aria-live="polite"></div>That’s the whole client side — a hidden field. The guarantees are server-side:
The contract
Section titled “The contract”- One key per rendered form — minted fresh (CSPRNG; UUID v4 is fine) every time the form renders, not per submit: every submit of this form instance claims the same intent.
- First commit: process, store
key → (request-hash, response), answer normally. - Replayed key, same payload: replay the stored response —
status, the headers that matter (
HX-Trigger,Location), body. A duplicate is indistinguishable from the first success; answering 409 would punish the user for the network. - Replayed key, different payload: a real conflict —
422naming what already exists. Same intent token + different content is a bug or a stale tab, not a retry. - Spend the key on commit only. A
422validation failure leaves the key live, so the corrected resubmit (same form, same key) can commit. Get this backwards and users can never fix a validation error. - Scope + TTL are declared policy — per user × per form, hours
not forever (
key, user, request_hash, response, created_at). After expiry, business-level uniqueness rules are the backstop.
| Request | Response |
|---|---|
| fresh key | the normal outcome — 200 fragment, 303 redirect, or 422 validation |
| seen key, same payload | the stored response, replayed |
| seen key, different payload | 422 — names the existing record |
The 422 branches swap into #order-result, and htmx ≥ 2 does not
swap non-2xx responses by default — add the standard one-line
htmx:beforeSwap allowance the
field-errors recipe
documents. (The live demo works because the docs pages install it.)
Composition
Section titled “Composition”- PRG: the stored response for a redirect flow is the
303— a replay lands on the same receipt page, which is the right answer to “did my order go through?”. - Async job: the
stored response for a kick-off is the
202+ job card — both clicks watch the same job. - Workflow actions: its 409 covers losing to someone else; this key covers racing yourself.
- Session expiry’s replayed request carries the same key — that’s the point.
Progressive enhancement
Section titled “Progressive enhancement”Nothing here is JavaScript at all: the key is a hidden field and the guarantees are server-side. The no-JS full-page POST follows the same three branches.
Accessibility
Section titled “Accessibility”The result region is aria-live="polite"; the replayed response
should read like the original (the demo adds a hint line so the
mechanism is visible). The conflict is an hc-alert with
role="status".
Related
Section titled “Related”- Mutating form — the client-side guard this recipe backs with a server guarantee.
- Edit conflict — versioning for updates; this key is for creations.
- Session expiry — the replay machinery whose safety this recipe completes.