Concurrency & conflicts
Two clerks open the same purchase order. In a back office this is normal, not an edge case — approval queues, shared cost centres and month-end all concentrate people on the same records. The framework tracks dirty state and gives you the events; the conflict policy is yours, and this page states the one this project recommends.
Optimistic by default, with a version check
Pessimistic locking ("this record is locked by J. Kim") looks safer and behaves worse: locks outlive the browser tab that took them, and someone always has to unlock things on a Monday morning. Ship optimistic concurrency instead — let both users edit, and detect the collision at save:
<!-- Send what you think you're changing FROM. The server compares and
refuses if the record moved underneath you. -->
<form hx-patch="/po/88213" hx-target="#po-88213" hx-swap="outerHTML">
<input type="hidden" name="version" value="7">
…fields…
</form>
<!-- 409 response: the server re-renders the row/record as it NOW is,
with a banner naming who changed it. The user chooses. -->
<div class="bo-alert bo-alert--warning" role="alert">
<p><strong>J. Kim changed this record</strong> 2 minutes ago while you were editing.</p>
<div class="bo-cluster">
<button class="bo-btn bo-btn--secondary" type="button">Discard mine & reload</button>
<button class="bo-btn" type="button">Keep mine & overwrite</button>
</div>
</div> htmx 4 swaps non-2xx responses by default — the
opposite of 2.x, which dropped everything but 2xx and required the
opt-in this page used to teach. Return a 409 with a re-rendered
record and it reaches the user with no app code at all, unless a
global htmx.config.noSwap excludes that status
elsewhere:
// htmx 4 swaps a 409 by default — nothing to opt in. If a global
// noSwap config excludes it elsewhere, name the exception here:
// htmx.config.noSwap = htmx.config.noSwap.filter((s) => s + "" !== "409"); The version can be a row version, an
updated_at, or an ETag — what matters is that the save
carries the value the user started from, and the server
refuses when it no longer matches.
Never silently lose an edit
| Situation | What ships |
|---|---|
| Conflict on save (409) | Re-render with the server's current values plus a banner naming who changed it and when; offer reload-or-overwrite. Never merge silently, and never discard the user's typing to show them the new state. |
| Row-level conflict in a grid | Re-render just that row in the error state; the rest of the user's dirty rows survive. The editable grid saves per row precisely so one conflict is not a screen-wide rollback. |
| Record decided elsewhere | An approval that was already approved returns 409 and shows the decision that won — see the approval pattern. |
| Stale list | A list is a snapshot. Say when it was taken, or refresh it; a bulk action on stale rows should report per-row results rather than claiming blanket success. |
| Two tabs, same user | The same problem with the same fix — a version check does not care whether the other editor is a colleague or yourself. |
What the framework tracks for you
initRowEdit()keeps per-row dirty state and dispatchesbo:row-savewith the row and itsdata-row-id— your save code decides what to send, including the version.bo:cell-changefires on every committed cell edit, so you can mark a record dirty the moment anything changes.- Cancel restores a row to its last-saved values and re-fires input events, so derived totals revert with it — a cancelled edit never leaves stale numbers on screen.
- Nothing polls, nothing auto-saves, nothing reloads underneath the user. If your app wants live updates, drive them from your own events and swap fragments — the components survive being replaced.
Warn on navigate-away while dirty
(beforeunload) — four lines of app code, and the single
highest-value guard against lost work.