Skip to content

Concurrent edits

Two users can open the same record at the same time. They can both type changes into their copies of the edit form. The first one to save lands their change. The second one’s save is rejected — the system refuses to silently overwrite the changes the first user made. This page describes how that detection happens, what the user sees when it fires, and how to recover.

Every record carries a hidden version stamp called RowVersion. The stamp updates automatically every time the record is saved. When you open an edit form, you load the current RowVersion alongside the data. When you save, the form sends that RowVersion back as a precondition: only update this record if its current RowVersion still matches what I loaded.

If a second user has saved the record since you loaded it, the stamp has changed. The system sees the mismatch and rejects your save with a 412 Precondition Failed error.

When the rule fires, you see a message in the edit form:

This record has been modified by another user. Reload to see the latest version.

Your unsaved changes stay in the form — they aren’t lost — but you can’t save them on top of the newer version. You need to either reload to see what the other user did, or copy your changes elsewhere first.

  1. Note what you’d changed — copy any new values out of the form (or screenshot if there are many).
  2. Reload the page — this fetches the latest version of the record, including the other user’s changes.
  3. Reapply your changes on top of the latest version — now that you’re starting from the up-to-date data, your save will succeed.

If your changes were only an addition (a new note, a new tag) that doesn’t conflict with what the other user did, step 3 is straightforward. If you both edited the same fields, you’ll need to make a call about which value to keep.

The detection has two layers, both producing the same outcome from the user’s perspective:

Every PATCH and PUT request includes the RowVersion as an If-Match header. The command handler checks it as the first thing it does — before any database work. This is the “user loaded an hour ago, someone else has saved since” case. It returns the 412 cleanly.

There’s a tiny window between the precondition check passing and the save committing in which a third party could still update the record. EF Core detects this race at commit time and throws an exception. A global filter translates that exception into the same 412 response so the experience is consistent regardless of which layer caught the conflict.

Where this lives: src/services/api/app/app.api/Filters/DbConcurrencyExceptionFilter.cs.

The check is per record, not per session or per page. If you’re editing two related records in different tabs (say an address in one tab and a scheme that uses that address in another), the two saves are independent — one can succeed while the other gets caught.

A small number of low-traffic records don’t carry a RowVersion stamp. In those cases the rule above doesn’t apply: a “last writer wins” race is theoretically possible but in practice these are reference-data records that only Admins touch. Every primary entity (Address, Scheme, Company, Investment Event, Occupier Event, Portfolio, Market Boundary) tracks concurrency.

sequenceDiagram
participant A as User A
participant B as User B
participant S as Server
A->>S: Load Scheme 42 (RowVersion = V1)
B->>S: Load Scheme 42 (RowVersion = V1)
A->>S: Save (If-Match: V1) → succeeds, V1 → V2
B->>S: Save (If-Match: V1)
Note over S: V1 ≠ V2, reject
S-->>B: 412 Precondition Failed
Note over B: "This record has been modified by another user..."
B->>S: Reload Scheme 42 (RowVersion = V2)
B->>S: Save (If-Match: V2) → succeeds, V2 → V3
  • Every entity page’s On save section references this — the per-entity 412 path goes through here.
  • Per-entity If-Match check: each Patch*CommandHandler.cs and Replace*CommandHandler.cs (e.g. ReplaceSchemeCommandHandler.cs:42)
  • Global exception filter: src/services/api/app/app.api/Filters/DbConcurrencyExceptionFilter.cs
  • Filter registration: src/services/api/app/app.api/Program.cs (AddControllers)
  • RowVersion property on every entity: [Timestamp] public byte[]? RowVersion on each model under src/common/models/Models/