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