Consistency and correctness
The guarantees you actually get, and the three ways to make concurrent writes correct: optimistic version checks, transactional operation routes, and single-threaded objects. Includes the lost-update failure every team hits once.
This is the page an experienced architect turns to first, so it leads with the failure rather than the theory.
The lost update
Two users book the last slot. Both requests read, both see it free, both write.
Nothing failed. Both users received a confirmation. One booking no longer exists, and you will find out from a customer rather than from a monitor.
Serial query execution does not save you here: each statement is atomic, but the read and the write are separate statements, and another request interleaves between them.
Three fixes, in increasing strength
1. Optimistic concurrency — a version column
The cheapest correct fix. Add a version column and make the update conditional on the value you read:
UPDATE bookings
SET slot = ?, version = version + 1
WHERE id = ? AND version = ?
If another writer got there first, zero rows change — return a 409 and let the caller retry against fresh state. The write that would have been silently lost becomes a visible conflict.
Use it for any row more than one actor can modify. It costs one column.
2. A transactional operation route
When the invariant spans several statements — check availability, verify a limit, write, record an audit row — put it behind a single route that owns the whole decision:
// POST /_spike/operations/book-slot
export async function bookSlot(env: Env, input: BookInput, actor: string) {
verifyCallerFirst(env, input); // required — the build enforces it
const slot = await env.DB.prepare(
'SELECT id, version, booked_by FROM slots WHERE id = ?'
).bind(input.slotId).first<Slot>();
if (!slot) throw new Error('unknown slot');
if (slot.booked_by) throw new ConflictError('already booked');
const res = await env.DB.prepare(
'UPDATE slots SET booked_by = ?, version = version + 1 WHERE id = ? AND version = ?'
).bind(actor, slot.id, slot.version).run();
if (res.meta.changes === 0) throw new ConflictError('raced — retry');
await emitAppEvent(env, 'booking.created', { slotId: slot.id }, { actor });
}
Two properties make this the workhorse pattern:
- One place holds the invariant. Every caller — an HTTP route, an AI agent, a scheduled job, an admin script — goes through it, so the rule cannot be bypassed by adding a caller.
- The platform enforces authentication on it. An operation route that skips caller verification fails the build, because an unauthenticated operation is a write path anyone on the internet can invoke.
These routes are also publishable as agent tools, which is how an agent changes contended data without improvising SQL. See Writing an agent.
3. A Durable Object per resource
The strongest guarantee: route every request for one key to one single-threaded object.
There is no race to lose, because there is no concurrency inside the object. Reach for this when ordering itself is the requirement, or when contention is high enough that conflict-and-retry becomes the common path rather than the exception. The cost is a throughput ceiling per object and no point-in-time recovery for its state — so record the outcome in D1 too.
Choosing between the three: version checks for ordinary contended rows; an operation route whenever the invariant spans more than one statement (most business rules); a Durable Object when strict ordering or high contention is inherent to the domain.
Read consistency
Separate question from write correctness: when you read, how fresh is the answer?
| Store | Guarantee |
|---|---|
| D1 primary | Strongly consistent |
| D1 replica via request session | At least as fresh as this visitor's own last write |
| D1 replica without a session | May be behind |
| Durable Object | Strongly consistent within that object |
| KV | Eventually consistent — seconds |
| R2 | Read-after-write for new objects |
The default is right for roughly ninety-five percent of routes: query c.get('db') and each visitor reads their own writes, while replicas absorb the read volume.
Read the primary explicitly when cross-user freshness is mandatory:
// Checkout, payment, inventory, uniqueness, redemption limits,
// or a webhook reacting to another actor's write
await c.env.DB.withSession('first-primary').prepare('…').bind(…).first();
And use the plain binding in non-HTTP entry points — scheduled handlers, queue consumers, operation routes — where there's no visitor session to carry a bookmark and writes go to the primary anyway.
Two rules that are easy to break by accident: never create a session at module scope (it would be shared between users who each need their own), and never share a bookmark anywhere but that visitor's own cookie.
Where correctness must not live
A recurring failure in AI-assisted systems, worth stating explicitly because this platform makes it easy to do the wrong thing:
Business rules belong in domain code, not in a prompt.
A limit written into an agent's instructions is guidance a model can be argued out of, and it constrains only that one caller. The same limit in a domain function binds the agent, your HTTP routes, your scheduled jobs, and your admin tooling — and it still binds after a human approves the action, which is exactly when a prompt-only rule evaporates. Approvals covers the interaction.
Next
- Durability, backup and recovery — what happens after the write lands.
- Failure domains and resilience — behavior when a store degrades.
- Database — sessions, indexes, and migrations in practice.
Frequently asked questions
Can two users double-book the same slot?
With plain reads and writes, yes — and it won't show up in testing, because tests don't race. Preventing it takes one of three mechanisms: a version check, a transactional operation route, or a Durable Object per resource.
Will a user always see their own writes?
Yes, if you query through the request session — each visitor carries a bookmark so any replica serving them is at least as fresh as their last write. Bypassing the session is what breaks it.
When must we read the primary rather than a replica?
When freshness across different users is mandatory: checkout, payment, inventory decrement, uniqueness or redemption limits, and webhooks reacting to another actor's writes.
Are database writes transactional?
A single statement is atomic. For an invariant spanning several statements or requiring a read-then-write, use a transactional operation route so the check and the write cannot be interleaved by another caller.
Why not just put the rule in the AI agent's instructions?
Because a prompt is guidance a model can be argued out of, and it binds only that caller. A check in domain code binds every caller — agent, HTTP route, scheduled job, or human — including after a human approves the action.