Concurrency and correctness

Understand storage gates and transactions

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

8 minute lesson

~~~

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 and course as PDF and EPUB files.

Get the download library →