Concurrency and correctness

Understand storage gates and transactions

Use Durable Object serialization and SQLite transactions without blocking every request manually.

One object handles its own requests one at a time. On top of that, the runtime puts input and output gates around storage. These two mechanisms are why Durable Object code mostly reads like single-threaded code.

The input gate closes while a storage operation is in flight. No other event reaches the object mid-write, so another request can’t see half-updated state. The output gate holds outgoing network messages until pending writes are confirmed durable. You never tell a client “saved” about data that then fails to persist.

Here’s the misconception that causes real bugs. The gates protect you around storage operations, not around every await. When your method awaits an external fetch, the input gate is open, and another event can run:

async reserve(seat, userId) {
  const taken = this.ctx.storage.sql
    .exec('select count(*) as n from reservations where seat = ?', seat)
    .one().n

  await notifyPaymentProvider(userId)   // gate OPEN: others interleave here

  if (taken === 0) {
    this.ctx.storage.sql.exec(          // decision based on stale read
      'insert into reservations (seat, user_id) values (?, ?)', seat, userId)
  }
}

Two callers both read taken === 0. Both pause at the fetch. Both insert. Single-instance execution did not save you, because the check and the write were separated by external I/O.

Let the database enforce the invariant

Group related SQL changes together and put the invariant in the database, where interleaving can’t reach it:

this.ctx.storage.sql.exec(`
  create table if not exists reservations (
    seat text primary key,
    user_id text not null
  )
`)

// in reserve():
try {
  this.ctx.storage.sql.exec(
    'insert into reservations (seat, user_id) values (?, ?)', seat, userId)
} catch (e) {
  return { ok: false, reason: 'seat already taken' }
}

The primary key on seat makes double booking impossible, no matter how requests interleave. For changes that span several statements, this.ctx.storage.transactionSync(() => { ... }) commits them atomically. SQLite gives you atomic changes without a process-wide lock.

A related habit: use blockConcurrencyWhile for initialization, not for normal request handling. It’s tempting as a universal mutex, but long blocks destroy throughput. Every event to the object queues behind it.

Try this the hostile way. Run concurrent seat reservations against one object, with a deliberate await between the check and the write. Verify that a unique constraint or a transaction prevents the double booking. If your test never interleaves, add a small delay in the middle until it does. Then make the constraint win.

Lesson completed