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

  1. Published. Date set to the real publish day, one after the planned Aug 24 slot.
  2. Created; body written from the project's design records.