Concurrency and correctness

Understand storage gates and transactions

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

One object coordinates its own requests, and the runtime provides 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 is delivered to the object mid-write, so another request can’t observe half-updated state. The output gate holds outgoing network messages until pending writes are confirmed durable, so you never tell a client “saved” about data that then fails to persist.

Here is 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 can both read taken === 0, interleave at the fetch, and 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 in a transaction and enforce invariants in the database, where interleaving can’t reach them:

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 multi-statement changes, this.ctx.storage.transactionSync(() => { ... }) commits them atomically. SQLite statements can express atomic changes without a process-wide lock.

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

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

Lesson completed

Take this course offline

Get every free book, course edition, and software download.

Get the download library →