Two devices, one snapshot
When both sides write to the same key, last-write-wins is data loss. Here is the alternative.
A one-shot transfer has a single writer, so nothing can go wrong. A per-account backup is
different: the laptop and the phone both write to user-42, and S3's default is last-write-wins.
The laptop reads, the phone reads, the laptop writes, the phone writes — and the laptop's work is
gone, with no error anywhere.
Conditional writes
Pass the ETag you last read as ifMatch. The write only lands if the stored snapshot is still the
one you based it on:
import { isS3ndError } from 's3nd'
const current = await store.getSnapshot<AppState>(`user-${userId}`)
try {
await store.putSnapshot(`user-${userId}`, merge(current?.data, incoming), {
ifMatch: current?.etag,
version: SCHEMA_VERSION,
})
} catch (error) {
if (isS3ndError(error) && error.code === 'PRECONDITION_FAILED') {
// Someone else wrote in between. Read again and merge again.
}
throw error
}PRECONDITION_FAILED is not a failure of the system — it is the system telling you the truth it
would otherwise have hidden. What you do with it is a product decision:
- Retry the read-merge-write loop. Correct when your merge is deterministic. Cap the attempts; a loop that never converges is worse than an error.
- Ask the user. "This account was updated on another device 2 minutes ago." Two buttons. Honest, and often the right answer for a local-first app.
- Refuse. Fine for a manual "back up now" button.
async function saveWithRetry(userId: string, incoming: AppState, attempts = 3): Promise<void> {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const current = await store.getSnapshot<AppState>(`user-${userId}`)
try {
await store.putSnapshot(`user-${userId}`, merge(current?.data, incoming), {
ifMatch: current?.etag,
version: SCHEMA_VERSION,
})
return
} catch (error) {
if (isS3ndError(error) && error.code === 'PRECONDITION_FAILED') continue
throw error
}
}
throw new Error('Could not save: the account kept changing underneath')
}Note ifMatch: current?.etag when current is null: no ETag means no condition, so the very
first write for an account goes through. If you want that first write to be exclusive too, use
ifAbsent instead.
Claiming a key
ifAbsent: true writes only if nothing is stored yet:
await store.putSnapshot(code, state, { ifAbsent: true })For a generated sync code the odds of a collision are tiny, but "tiny" and "impossible" differ, and the failure mode without the check — silently overwriting a stranger's transfer — is bad enough to be worth one header.
What this does not solve
Conditional writes tell you a conflict happened. They do not merge anything: merge() above is
your code, and writing a correct one over arbitrary application state is genuinely hard. Deletions
are the classic trap — a record missing from the incoming snapshot could be one the user deleted,
or one the other device has not seen yet, and nothing in the data distinguishes them.
If your app needs real concurrent editing, a snapshot store is the wrong shape. Use a CRDT library
so merges are defined by the data structure, and keep s3nd as the place its state lives:
putSnapshot() will happily store a serialized CRDT document.
For the common case — one person, several devices, one at a time — read-merge-write with ifMatch
is enough, and it never loses work silently.