Concurrency and correctness

Handle external I/O and retries

Expect interleaving around fetch calls and make outbound side effects safe to repeat after failure.

8 minute lesson

~~~

A Durable Object gives you serialized access to its own storage. The moment your method calls an external API, you leave that safe zone. Storage coordination does not make an external API transactional: while an object awaits network I/O, another event can run, and a retry can repeat the external side effect.

Timeouts are the sharpest edge. A timed-out charge request has three possible truths — the provider never received it, received it and failed, or received it and succeeded while the response got lost. Your code cannot tell which. Assume “timeout means nothing happened” and you will eventually charge someone twice.

Persist intent before acting

The pattern: persist an operation ID and state transition before the call, pass an idempotency key to the provider, then record completion.

async charge(orderId, amountCents) {
  const operationId = `charge-${orderId}`

  const claimed = this.ctx.storage.sql.exec(
    `insert into operations (id, state, amount_cents)
     values (?, 'pending', ?)
     on conflict (id) do nothing`,
    operationId, amountCents
  ).rowsWritten

  if (claimed === 0) return this.operationStatus(operationId)

The insert claims the operation exactly once. A duplicate delivery of the same order finds the row already there and reads its status instead of charging again.

Then make the provider deduplicate too:

  try {
    await fetch('https://api.pay.example.com/charges', {
      method: 'POST',
      headers: { 'Idempotency-Key': operationId },
      body: JSON.stringify({ amount: amountCents }),
      signal: AbortSignal.timeout(10_000),
    })
    this.ctx.storage.sql.exec(
      `update operations set state = 'confirmed' where id = ?`, operationId)
  } catch (e) {
    this.ctx.storage.sql.exec(
      `update operations set state = 'uncertain' where id = ?`, operationId)
  }
}

The Idempotency-Key header means that even if your retry does reach the provider twice, it performs the charge once. Serious payment APIs support this; if yours doesn’t, the operation ID in your storage is the only guard you have.

Uncertain is a real state

Notice the failure path writes uncertain, not failed. Reconcile operations left in an uncertain state rather than assuming a timeout means nothing happened: a later pass — an alarm works well — queries the provider by idempotency key and settles the row to confirmed or failed based on what actually happened.

Design a payment-like call that is delivered twice and times out once without charging twice. Walk the three-state table through both events. If every path ends with one charge and a settled row, the design holds.

Lesson completed

Take this course offline

Get every free book and course as PDF and EPUB files.

Get the download library →