busy-office-ui
Menu

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 &amp; reload</button>
    <button class="bo-btn" type="button">Keep mine &amp; 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

SituationWhat 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 gridRe-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 elsewhereAn approval that was already approved returns 409 and shows the decision that won — see the approval pattern.
Stale listA 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 userThe 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 dispatches bo:row-save with the row and its data-row-id — your save code decides what to send, including the version.
  • bo:cell-change fires 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.

↑↓ move Enter open Esc close