Skip to content
← All writing

One Durable Object per agent

7 min readUpdated
.md
Cover illustration for One Durable Object per agent

TL;DR

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 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:

ConcernShared database, stateless workerDurable Object per agent
Request orderingApplication code, via locks or versioningPlatform guarantee, single-threaded per object
Conversation appendsOptimistic locking or a transactionPlain insert
Read-your-writesRace between tool result and next turnNot possible within an object
Rate budget under concurrent messagesDistributed lockSerialised by the object
Cross-tenant reportingOne queryStreamed out to an analytics store
Storage lifecycleOne database to manageOne 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, and storage operations are backed by a SQLite database per object.

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, and exec() takes a query plus positional bindings:

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:

[[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 or an alarm 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-agentEphemeral queue object
WhenWork fits inside the turnWork exceeds a few seconds
Parent during the callBlockedStays responsive
Ledger writeSame write as the turnWritten back to the parent when done
What the user seesThe answerA task ID now, a proactive message on completion
LifecycleNone, it is a function callCreated for the job, deletes itself after
Survives eviction viaNot applicablectx.waitUntil or an alarm

The costs, honestly

This is not free.

CostWhy it happensWhat I do about it
Per-object storageEvery agent carries its own SQLite databaseA lifecycle policy, written before the object count gets large
Cross-agent queriesNo shared table to aggregate overStream metering events to an analytics store
Cold startsAn untouched object pays a wake-up costAccept 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:

LimitValueWhy it matters here
Storage per object10 GB on the paid planA per-agent ceiling, not a shared one. A busy agent hits it alone
Maximum row, string or BLOB2 MBA large tool output has to be stored by reference, not inline in the ledger
Columns per table100Fine for a ledger, worth knowing before you widen the schema
SQL statement length100 KBBatch inserts need chunking
Bound parameters per query100Same

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

Common questions

Why use a Durable Object per agent instead of a shared database?

Requests for the same agent ID land on the same single-threaded object, in order, so serialisation is a platform guarantee rather than application code. That removes optimistic locking on conversation appends, read-your-writes races between a tool result and the next turn, and distributed locks around a rate budget.

How do you store a task ledger inside a Durable Object?

Take the SQLite handle off the context in the constructor with ctx.storage.sql, then create a tasks table and insert a row per unit of work. Because each row carries a parent_id, the full delegation tree comes back from one recursive query rather than being reconstructed from log lines.

What are the downsides of one Durable Object per agent?

Three. Storage is per object, so a large agent count means a large number of small databases needing a lifecycle policy. Cross-agent questions like tenant token spend cannot be a single query and need metering events streamed to an analytics store. Objects that have not been touched pay a cold start.

When should an agent delegate to an ephemeral object instead of calling synchronously?

Use a time budget rather than a task type. If the estimated work fits inside the turn, call the sub-agent synchronously and let the result land in the same ledger write. If it exceeds a few seconds, spawn an ephemeral object so the parent stays responsive and the user gets a task ID immediately.

What Wrangler configuration does a SQLite-backed Durable Object need?

A binding for the class plus a migration using new_sqlite_classes rather than the older key-value class binding. Without the SQLite migration tag the object will not have the ctx.storage.sql handle available.

Written by Elson Tan, Head of Technology and co-founder at Nedex Group, working on AI harness and agent infrastructure.

AboutRSS
  • 8 min read

    The job was never the code

    Most of my code is now written by an agent, and my output went up rather than down. That is not a story about typing speed. It is about what the job always was underneath the typing.

  • 6 min read

    Sandboxing code an agent wrote for you

    Once an agent writes and runs code, you are executing untrusted input on your infrastructure. The isolation question has known answers. The harder problems are quotas, cancellation and getting results out.

  • 5 min read

    Crawling a knowledge base without a crawler service

    Rendering every page in a headless browser is the expensive way to import a website. Discovery through robots.txt and sitemaps costs almost nothing, and most pages never need a browser at all.

Get in touch

Tell me who you are and what you are working on.

Your details are used only to reply to this message.