# One Durable Object per agent

Why I gave every AI agent its own Durable Object with a SQLite task ledger, and what that bought in state, concurrency and debuggability.

By Elson Tan (https://elsontan.com)
Published: 2026-06-02
Updated: 2026-08-01
Reading time: 7 min
Tags: Cloudflare, Architecture, Agents
Canonical: https://elsontan.com/blog/one-durable-object-per-agent/

> Giving each AI agent its own Durable Object with an embedded SQLite ledger makes request ordering a platform guarantee rather than application code. It removes optimistic locking, read-your-writes races and distributed locks, at the cost of harder cross-agent analytics and a per-object storage lifecycle.

---
When you build a multi-tenant agent platform, the first architectural question is where
conversation state lives. The default answer is a shared database keyed by conversation ID,
with a stateless worker reading and writing rows. It works, and it is what most teams reach
for. I went the other way: **one [Durable Object](https://developers.cloudflare.com/durable-objects/)
per agent**, holding conversation state, memory and a task ledger in its own embedded SQLite.

## What does a single-tenant object actually buy you?

A Durable Object is a single-threaded, addressable actor with strongly consistent storage
attached. Requests for the same agent ID land on the same object, in order. That property
alone removes an entire class of problem:

- No optimistic locking around conversation appends.
- No read-your-writes race between a tool result and the next turn.
- No distributed lock to stop two concurrent messages double-spending a rate budget.

Set against the default shared-database approach:

| Concern | Shared database, stateless worker | Durable Object per agent |
| --- | --- | --- |
| Request ordering | Application code, via locks or versioning | Platform guarantee, single-threaded per object |
| Conversation appends | Optimistic locking or a transaction | Plain insert |
| Read-your-writes | Race between tool result and next turn | Not possible within an object |
| Rate budget under concurrent messages | Distributed lock | Serialised by the object |
| Cross-tenant reporting | One query | Streamed out to an analytics store |
| Storage lifecycle | One database to manage | One database per agent to manage |

The concurrency model is the feature. You get serialisation because the platform gives it to
you, not because you built a queue. Cloudflare documents the guarantee directly: each object
has [a single-threaded execution model](https://developers.cloudflare.com/durable-objects/platform/known-issues/),
and storage operations are backed by
[a SQLite database per object](https://developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-storage/).

## The task ledger

Conversation history alone is not enough state for an agent that does real work. Long-running
tool calls need to survive an eviction, and a user needs to be able to ask what happened. So
every unit of work goes into a ledger table inside the object.

The class extends `DurableObject` from `cloudflare:workers` and takes the SQLite handle off
the context in the constructor. That handle is
[`ctx.storage.sql`](https://developers.cloudflare.com/durable-objects/api/sql-storage/), and
`exec()` takes a query plus positional bindings:

```ts
import { DurableObject } from 'cloudflare:workers'

export class Agent extends DurableObject {
  sql: SqlStorage

  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env)
    this.sql = ctx.storage.sql

    this.sql.exec(`
      CREATE TABLE IF NOT EXISTS tasks(
        id          TEXT PRIMARY KEY,
        parent_id   TEXT,
        kind        TEXT NOT NULL,
        status      TEXT NOT NULL,
        input       TEXT NOT NULL,
        output      TEXT,
        tokens_in   INTEGER DEFAULT 0,
        tokens_out  INTEGER DEFAULT 0,
        created_at  INTEGER NOT NULL,
        finished_at INTEGER
      );
    `)
  }

  recordTask(id: string, parentId: string | null, kind: string, input: unknown) {
    this.sql.exec(
      `INSERT INTO tasks (id, parent_id, kind, status, input, created_at)
       VALUES (?, ?, ?, 'running', ?, ?)`,
      id,
      parentId,
      kind,
      JSON.stringify(input),
      Date.now()
    )
  }

  // A delegation tree falls out of one recursive query.
  tree(rootId: string) {
    return this.sql
      .exec(
        `WITH RECURSIVE walk(id) AS (
           SELECT id FROM tasks WHERE id = ?
           UNION ALL
           SELECT t.id FROM tasks t JOIN walk w ON t.parent_id = w.id
         )
         SELECT t.* FROM tasks t JOIN walk USING (id) ORDER BY t.created_at`,
        rootId
      )
      .toArray()
  }
}
```

The class needs a SQLite-backed migration in the Wrangler config, not the older key-value
class binding:

```toml
[[durable_objects.bindings]]
name = "AGENT"
class_name = "Agent"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["Agent"]
```

Because `parent_id` is there, a delegation tree is one recursive query. When someone asks why
an agent answered the way it did, you can show them the actual tree of sub-agent calls with
token counts on each node, rather than reconstructing it from log lines.

## Delegation: synchronous or ephemeral

The orchestrator inside an agent object has two ways to hand off work.

For anything short, it calls the sub-agent synchronously and blocks the turn. Simple, and the
result lands in the same ledger write.

For anything long, it spawns an ephemeral queue object: a separate Durable Object created for
that one job, which does the work, writes back to the parent's ledger, and then deletes
itself. The parent stays responsive. The user gets a task ID immediately and a proactive
message when it finishes. Work that must survive the request uses
[`ctx.waitUntil`](https://developers.cloudflare.com/workers/runtime-apis/context/) or an
[alarm](https://developers.cloudflare.com/durable-objects/api/alarms/) rather than a dangling
promise.

The rule I settled on is a time budget, not a task type. If the estimated work exceeds a few
seconds, it goes ephemeral.

| | Synchronous sub-agent | Ephemeral queue object |
| --- | --- | --- |
| When | Work fits inside the turn | Work exceeds a few seconds |
| Parent during the call | Blocked | Stays responsive |
| Ledger write | Same write as the turn | Written back to the parent when done |
| What the user sees | The answer | A task ID now, a proactive message on completion |
| Lifecycle | None, it is a function call | Created for the job, deletes itself after |
| Survives eviction via | Not applicable | `ctx.waitUntil` or an alarm |

## The costs, honestly

This is not free.

| Cost | Why it happens | What I do about it |
| --- | --- | --- |
| Per-object storage | Every agent carries its own SQLite database | A lifecycle policy, written before the object count gets large |
| Cross-agent queries | No shared table to aggregate over | Stream metering events to an analytics store |
| Cold starts | An untouched object pays a wake-up cost | Accept it for chat, reconsider for a latency-critical synchronous API |

**Storage is per object.** A million agents means a million small SQLite databases. Cheap per
unit, but you need a lifecycle policy, and you need it before you have a million of them. The
limits that actually shape the design, from
[the Durable Objects limits page](https://developers.cloudflare.com/durable-objects/platform/limits/):

| Limit | Value | Why it matters here |
| --- | --- | --- |
| Storage per object | 10 GB on the paid plan | A per-agent ceiling, not a shared one. A busy agent hits it alone |
| Maximum row, string or BLOB | 2 MB | A large tool output has to be stored by reference, not inline in the ledger |
| Columns per table | 100 | Fine for a ledger, worth knowing before you widen the schema |
| SQL statement length | 100 KB | Batch inserts need chunking |
| Bound parameters per query | 100 | Same |

Worth knowing what happens at the ceiling: writes fail with a database-full error while reads
and deletes keep working. That is the right way round for a cleanup job, but it means the
lifecycle policy has to exist before an agent gets there, not after.

**Cross-agent queries are hard.** Asking "how many tokens did this tenant spend today" cannot
be a single query against a shared table any more. I stream metering events out to an
analytics store and treat the objects as the source of truth for behaviour, not for
reporting.

**Cold starts exist.** An object that has not been touched in a while pays a wake-up cost.
For a chat interface it is unnoticeable. For a latency-critical synchronous API it might not
be.

For an agent platform, where the workload is naturally partitioned by agent and the hard
problems are ordering and state rather than analytics, the trade has been clearly worth it.

## Sources

- [Durable Objects overview](https://developers.cloudflare.com/durable-objects/), Cloudflare
- [Durable Objects SQL storage API](https://developers.cloudflare.com/durable-objects/api/sql-storage/), Cloudflare
- [Access Durable Objects storage](https://developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-storage/), Cloudflare
- [Durable Objects alarms](https://developers.cloudflare.com/durable-objects/api/alarms/), Cloudflare
- [Durable Objects limits](https://developers.cloudflare.com/durable-objects/platform/limits/), Cloudflare
