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.

The Spikefrost Team30 Jul 20264 min read

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.

The classic lost update. Nothing errors, both users are told they succeeded, and one booking silently overwrites the other.D1Request BRequest Aboth believe the slot is availableA's booking is gone.No error was raised anywhere.SELECT slot WHERE id=421slot = free2SELECT slot WHERE id=423slot = free4UPDATE slots SET booked_by='A' WHERE id=425ok6UPDATE slots SETbooked_by='B' WHERE id=427ok8
The classic lost update. Nothing errors, both users are told they succeeded, and one booking silently overwrites the other.

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.

With a version check the second writer changes no rows and receives a conflict, instead of silently overwriting the first.D1Request BRequest A409 Conflict — re-read and retry.Nothing was lost.SELECT slot, version WHERE id=421free, version=72SELECT slot, version WHEREid=423free, version=74UPDATE … WHERE id=42 AND version=751 row changed → version=86UPDATE … WHERE id=42AND version=770 rows changed8
With a version check the second writer changes no rows and receives a conflict, instead of silently overwriting the first.

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.

A Durable Object processes one request at a time for its key, so the interleaving that causes lost updates cannot occur — correctness by construction rather than by careful coding.D1DO slot-42Request BRequest Asingle-threaded —B waits herebook(A)1record booking2confirmed3book(B)4already booked5
A Durable Object processes one request at a time for its key, so the interleaving that causes lost updates cannot occur — correctness by construction rather than by careful coding.

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

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.