The update endpoints in my timer application accepted saves without checking whether the record had changed since the client loaded it. Editing from a phone and a laptop could let one save overwrite another.
Expense reports made the risk concrete. An update replaces the report’s item set, so saving an older copy could remove line items added in another session. A title edit could carry stale items along with it.
The API needed a way for a client to say: apply this change only if the resource still matches the version I read.
The timestamp couldn’t distinguish enough states
The existing updated_at values have one-second resolution. Two updates within that second can produce the same timestamp, so comparing it wouldn’t reliably detect a stale edit.
That doesn’t require same-second writes to be the most common collision. They only need to be possible for the timestamp to be an inadequate version token.
An ETag with If-Match provides an HTTP mechanism for conditional updates, but the server still has to choose what the tag represents. A revision counter is one option. This implementation instead hashes the resource’s canonical JSON with SHA-256, avoiding a schema change.
Canonical JSON means a deterministic serialization of the state included in the comparison. Field selection and ordering matter: equivalent state should produce the same bytes, and changes that need protection must affect those bytes.
The hash identifies that serialized state, not a unique event in its history. If the included state changes and later returns to exactly what it was, its hash returns too. A revision counter would make a different promise by tracking intervening updates.
Clients receive an ETag when reading a resource and can send it back in If-Match. Requests without that header retain the existing last-write-wins behavior. The protection is opt-in; it doesn’t prevent an unguarded caller from overwriting another edit.
Compare and write in the same transaction
In this implementation, the application computes the hash from the resource rather than comparing a stored version column in a SQL WHERE clause.
That means reading, hashing, comparing, and writing need to happen within the transaction that performs the update. Comparing first and opening the transaction afterward would leave room for another writer between the check and the write.
Projects and tasks gained transactions as part of this change. The transaction boundary also matters for the response: the new tag is computed from the post-update state inside the transaction, and success must wait until commit succeeds.
A separate read after commit could observe another session’s later update. Returning that newer tag alongside the earlier response would give the client a validator for state it hadn’t received.
The response and its tag need to describe the same version of the resource.
Contention is another outcome to handle
A transaction doesn’t mean every competing request completes successfully. Its behavior depends on the database, journal mode, and how the driver starts it.
In SQLite’s WAL mode, two deferred transactions can read the same state. If one commits a change, the other can fail with SQLITE_BUSY_SNAPSHOT when it tries to promote its stale read transaction to a write. Waiting doesn’t refresh that snapshot; recovery requires ending the transaction and starting again.
That’s different from saying every busy or locked error proves this resource changed. Some failures reflect broader database contention. The API should avoid presenting all of them as evidence of a stale client copy.
The application’s tests and deployed service use different database drivers. That makes driver behavior part of what needs verification. A transaction option accepted in one environment isn’t evidence that another driver supports it, and requesting serializable isolation isn’t synonymous with issuing BEGIN IMMEDIATE.
Error handling needs the same care. Structured error codes are preferable where available. Matching text from the entire wrapped error can accidentally match query context or user-supplied values. Looking at the underlying error reduces that risk, but text matching still needs narrow rules and tests.
The conflict response is an API choice
The implementation returns 409 with current server state for its conflict response. For a failed If-Match precondition, the standard status is 412, so this is an API deviation, not a requirement of the response body.
A 412 response can carry useful content too. Returning information that helps the client reload or compare changes doesn’t require choosing 409.
The client also needs to treat a rejected write as a decision point. Reloading and showing the differences is different from silently fetching a new tag and retrying the old payload, which could overwrite the change the precondition protected.
I want these tests to cover both a visibly stale tag and competing writes that initially read the same state. Testing only the first case would miss the transaction behavior.
The token and the transaction solve different problems. The token identifies the state the client saw. The transaction’s isolation and failure handling prevent a competing write from invalidating the comparison unnoticed.
Sources
- RFC 9110: If-Match — conditional updates and failed preconditions
- SQLite transactions — deferred and immediate transactions
- SQLite isolation — WAL snapshots and stale-snapshot write failures
- Go errors package — inspecting wrapped errors by type
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].