# Optimistic concurrency without conditional PUT

Jira's worklog update API has no conditional PUT — no ETag, no If-Match; whoever writes last wins blind. This log covers the conflict detection I shipped for an internal time-tracking tool that syncs to Jira: persist the upstream updated timestamp, carry it to the edit form as a baseline, and compare with one read before every write.

Published: 2026-08-25
Canonical: https://umar.codes/optimistic-concurrency

## Revisions

- 2026-07-30 — Created; body written from the project's design records.
- 2026-08-25 — Published. Date set to the real publish day, one after the planned Aug 24 slot.

---

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.
