Mocking a Race Between Two Clients: A Deterministic Approach
Quick answer: A race between two clients happens when both act on the same resource and the result depends on whose request lands first, which is exactly what makes it hard to reproduce. Mock the race by replacing timing luck with conditional responses and fixed latency, so each interleaving is forced instead of hoped for. Then assert that the losing client handles a 409 or 412 instead of silently overwriting the other change.
Why a two-client race is hard to reproduce
Picture two people opening the same record, editing it, and saving. Each client reads a version, works on it, and writes it back. If the server accepts both writes without comparing versions, the second save overwrites the first and one set of changes disappears. The same pair of actions can produce a lost update on one run and a clean save on the next, because the outcome depends on scheduling you do not control. Repeating the test by hand rarely gives you the exact failing order on demand, so the bug stays intermittent and ships.
Common symptoms are a lost update, a last-write-wins overwrite, a write based on a value that was already stale, or a balance or inventory count applied twice. None of these show up in a single-client happy path.
Use the mock to remove timing luck
A local mock does not depend on real server scheduling, so you can script the interleaving. Match responses on the version or conditional header each request carries and fix the latency for each step, then drive the two clients through a known order. The table below shows the three cases worth covering.
| Interleaving | Mock condition | Response | Expected client behavior |
|---|---|---|---|
| A commits first | A's version matches | 200; B is now stale | B receives 409/412 and re-syncs |
| B commits first | B's version matches | 200; A is now stale | A receives 409/412 and re-syncs |
| No version guard | Both writes accepted | Two 200s | Last write silently wins, which is the bug |
Running both orderings matters: a client that only behaves when it wins the race still fails when it loses. The no-guard row is a useful control, since it proves the test can actually detect the overwrite rather than always passing.
How to set up the conditional responses
Give the mock a versioned resource and return the same current version to both clients on their first read. On write, compare the request's version or If-Match header against the current one: accept a matching write and advance the version, then reject the now-stale write with a 409 or 412. Use response overrides for each branch and set explicit latency so the requests interleave in the order you want, rather than racing the network. Check the recorded requests afterward to confirm the two calls arrived in the intended order and carried the versions you expected. A desktop API workspace lets you define these conditional overrides, pin the timing, and inspect the recorded requests in one place. Powerduck works this way, so a flaky timing bug becomes a fixed script you can rerun.
What the client must do
The client's job is to make conflicts visible, not to power through them. Send the version or ETag on write, and when a 409 or 412 comes back, fetch the latest resource, reconcile the local change against it, and let the user decide how to merge rather than retrying the same stale write. Add assertions for both the winning and losing paths so the conflict UI and the re-sync flow are exercised, not just the successful save.
Honest limits
A local mock runs in the desktop main process and does not replace the real backend, so the conditional responses you define prove how the client reacts to a conflict, not how a real database detects one. The mock does not model isolation levels, row locks, deadlocks, or true operating-system scheduling, and its "concurrency" is timing you orchestrate with latency. Keep a small set of real concurrent requests against the live API to confirm the actual conflict status and locking behavior. Importing a schema does not deploy a backend, and a passing mock run is not evidence about the server's concurrency guarantees.
Frequently asked questions
Is a mock race the same as real concurrency? No. The mock fixes the order and timing so you can test each interleaving deterministically. It does not reproduce real thread or database scheduling, so confirm the live behavior with a few real concurrent calls.
Which status codes should the losing write return? Typically 409 Conflict or 412 Precondition Failed when the version or ETag no longer matches. The client should re-sync and merge rather than resend the stale write.
How do I stop the second save from overwriting the first? Require a version or ETag on each write, compare it server side, and reject stale writes. The mock should return the same version to both readers first, then accept only the first matching write.
Why test both interleavings? Either client can lose the race. Covering only the order where your main client wins leaves the conflict handling for the other path untested.
The bottom line
Do not chase a two-client race by repeating the same manual steps and hoping for the failure. Script it in the mock with versioned conditional responses and pinned latency, cover both winning orders plus the no-guard control, and assert that the losing client re-syncs on a 409 or 412. Verify the real backend's locking separately. For more on this approach, see the API mocking guide hub.