Optimistic concurrency without conditional PUT
When an API offers no conditional writes — no ETag, no If-Match, nothing — you can still detect edit conflicts; you just have to build the machinery yourself. For an internal time-tracking tool syncing worklogs to Jira, that machinery is a stored upstream timestamp and one comparison read before every write. Here’s the design, including its honest limits.
The API that offers nothing: last-write-wins by default
Jira Cloud’s REST v3 worklog update is a plain PUT with no
optimistic-concurrency support: it accepts no If-Match, returns no usable
ETag, and applies whatever it’s given. Two people open the same worklog, both
edit, both save — the second save silently erases the first, and nobody is
told. The only version signal the API exposes is the worklog’s updated
timestamp, and it’s read-only: you can observe it, but you can’t make a write
conditional on it. Whatever protection users get has to be built client-side
from that one field.
Read before write: a poor man’s If-Match
The design is three moves. When the tool creates or edits a worklog, it
persists the updated timestamp from the API response alongside its own
record — the local row now carries the upstream version it last saw. When a
user opens an edit form, that stored timestamp travels with the form as the
baseline: “this is the version you are editing.” And at submit, before the
PUT, the tool does exactly one GET of the live worklog and compares
timestamps. Equal means nobody else has touched it — proceed, write, store
the new updated. Different means the worklog changed since the form was
opened — stop, tell the user, offer a reload. The failure mode changes from
silent overwrite to informed choice, which is the entire point.
One architectural note that took deliberate reasoning: this tool has a hard
rule that display reads never hit Jira live — calendars and reports come from
local rollups. The pre-write GET doesn’t violate that rule, because it
governs the read path, and this read belongs to the write path. Writing
the distinction down mattered; “never read live” rules rot into cargo cults
unless their scope is explicit.
The rules for a missing baseline
Rows created before this feature have no stored timestamp, and the rule for them is deliberately permissive: a null baseline can’t prove staleness, so the write proceeds and adopts the fetched timestamp for next time. The alternative — blocking every edit of an older row behind a warning nobody can act on — punishes users for the system’s own history. Conflict detection you can’t trust gets bypassed; detection that stays quiet until it’s sure gets believed.
Idempotency comes free on edit, not on create
A related asymmetry fell out of the same work. Retrying an edit or a delete is naturally safe: the operation targets an ID that already exists, so repeating it converges (the tool treats a 404 on delete as success — the goal state is “gone”, and it’s gone). Creation is the dangerous retry: repeat an ambiguous create and you’ve logged the hours twice. So creates carry an idempotency table, and edits and deletes deliberately don’t. Idempotency isn’t a property of your retry logic; it’s a property of which operation you’re retrying.
The honest limits
This is check-then-act, and the race is still there: between the comparison
GET and the PUT, someone else’s write can land, and it will be silently
lost. What the design changes is the size of the window — from the entire
minutes-long edit session down to the milliseconds between two consecutive
API calls. For worklogs, where concurrent edits are rare and the stakes are
an hours field, that trade is right. For anything where the residual window
matters, the answer isn’t a cleverer client — it’s an API that accepts a
version on the write, and no client-side pattern can conjure that from the
outside.
Revisions
- Published. Date set to the real publish day, one after the planned Aug 24 slot.
- Created; body written from the project's design records.