Skip to content

Edit conflict

Two people open the same record; the slower save must not silently eat the faster one. This recipe is optimistic locking as pure composition: a hidden version field, a 409 steered by HX-Retarget into the same shared dialog host the session-expiry recipe uses, and dialog buttons that finish the flow through ordinary htmx. Zero new JavaScript, zero new public API. Covered by the versioning policy.

Also known as: optimistic locking, concurrency control, 409 handling.

The demo form was rendered from v12 while the record sits at v13 — Save always conflicts, on purpose. Overwrite forces with the dialog’s fresh version; Reload discards your edits (and then saves cleanly).

This form was rendered from v12; the record is at v13 — Save to see the conflict.

<form id="ticket-form" data-hx-put="/tickets/7"
data-hx-target="#status" data-hx-swap="innerHTML">
<input type="hidden" name="version" value="12">
<div class="hc-field">
<label class="hc-field__label" for="title">Title</label>
<input class="hc-input" id="title" name="title" value="Restock the beans">
</div>
<p class="hc-field__hint" id="status" aria-live="polite"></p>
<button class="hc-button" data-variant="primary" type="submit">Save</button>
</form>
<div id="error-dialog" data-hc-remote-dialog-root></div>
CaseResponse
PUT with the current versionmutating-form success; the fragment carries the new version
PUT with a stale version409 + HX-Retarget: #error-dialog + HX-Reswap: innerHTML + the conflict dialog: theirs/yours table, Overwrite (…?force=1, data-hx-include of your fields plus the dialog’s fresh hidden version), Reload (GET …/edit swapping the form outerHTML), <form method="dialog"> to keep editing. Action buttons carry data-hc-close-dialog-on-success
PUT with force=1 + the fresh versionoverwrite wins; a record that moved again re-conflicts
GET …/editthe whole form re-rendered at the current version
no-JSa full 409 page with the same two choices (PRG)

The dialog’s version field is the current one from the moment of conflict — forcing with it means “I saw v13 and chose to overwrite”. It never comes from the losing form.

  • A real <dialog> (showModal() — native focus trap and Escape), named by aria-labelledby; the theirs/yours diff is a real table with row and column headers.
  • Outcomes land in the form’s aria-live status slot.