03 · DURABLE OBJECTS
SessionDO
The single most important class in Duraclaw. One instance per AI session, persists for its entire lifetime.
A regular Worker is a cashier — serves whoever's next, remembers nothing. A Durable Object is a personal banker — you're assigned to one, they know your history, you always talk to the same one.
Why Durable Objects are load-bearing
Every other pattern in Duraclaw falls out of one choice: put the session in a Durable Object.
Think about what a Claude Code session is: it's a long-running stateful conversation that belongs to you, keeps growing, and needs to survive you closing the browser. Classic web architectures handle this with a backend database + a stateless API + a frontend that polls. Lots of moving parts, lots of race conditions, lots of “did my message get through?”
A DO is a single global instance identified by name, with its own private SQLite. Asking for DO session-abc-123 always routes you to the exact same one — wherever it's currently running on Cloudflare's edge. That single-instance guarantee means no race conditions. The private SQLite means no external database. The long-lived WebSocket means no polling. One primitive replaces three layers of traditional backend infrastructure.
Once you have this session lives in this one named thing, everything else — the two-WebSocket chain, the replay-on-reconnect, the human-in-the-loop approvals — becomes implementable in a surprisingly small amount of code.
In plain terms
Single-threaded = exactly one instance is alive at any time, and requests are processed one at a time in arrival order. No two users can accidentally corrupt each other's data because there are no two copies to corrupt.
Eviction = Cloudflare shuts down an idle DO after a period of inactivity to free resources. The DO's code stops running, but its SQLite is preserved. Next request spins up a fresh instance, which reads state back from SQLite. The DO doesn't “know” it was evicted.
Alarms API = a DO can schedule “wake me up at timestamp X” and get called back even when no request arrives. Used for timeouts, retry logic, scheduled maintenance.
Migration (in the DO sense) = a SQL script (e.g.,
ALTER TABLE messages ADD COLUMN tool_results TEXT) paired with a version number. The DO keeps
PRAGMA user_version and runs only newer migrations on every wake-up. Schema evolves across deploys; existing sessions upgrade automatically.
Idempotent = running twice gives the same result as running once. Migrations must be idempotent because DOs wake up many times over their lifetime; each wake-up runs the migration loop.
What DOs Give You
Identity
Ask for DO session-abc-123 and you always get the same global instance.
Single-threaded
One copy worldwide. Requests processed in order. No race conditions.
Private SQLite
Each DO has its own SQL database. Survives restarts and data-center moves.
WebSocket native
DOs hold open sockets — the foundation for real-time messaging.
Alarms API
Schedule "wake me up at X" callbacks. Used for reconnection retries.
Eviction-safe
Idle DOs evict; on next request they spin back up and read state from SQLite.
What SessionDO Owns
Session stateinitializing → active → paused → terminated
Message history (SQLite)Every message, tool call, tool result — forever
WebSocket relayHolds WS #1 (browser) and WS #2 (VPS) simultaneously
Reconnect alarmsIf VPS drops, schedules retry via Alarms API
The Agents SDK Base Class
SessionDO doesn't extend raw DurableObject. It extends Agent from the agents package, which gives browser-side the useAgent() React hook — typed, auto-reconnecting WebSocket with no manual management.
// browser side — the entire connection in one line
const { messages, send } = useAgent({ name: "session-abc-123" })
DO Migrations
Need to add a column to session SQLite? Migrations are versioned and idempotent — run every wake-up, only new ones execute.
const migrations = [
{ version: 1, sql: `CREATE TABLE messages (...)` },
{ version: 2, sql: `ALTER TABLE messages ADD COLUMN tool_results TEXT` },
];
export function runMigrations(sql: SqlStorage) {
const current = sql.exec("PRAGMA user_version").one()?.user_version ?? 0;
for (const m of migrations) {
if (m.version > current) {
sql.exec(m.sql);
sql.exec(`PRAGMA user_version = ${m.version}`);
}
}
}
Idempotent = running twice gives the same result as running once. No duplicate "Phone" columns, no matter how many times the DO wakes up.
Walk-through
SessionDO session-abc-123 over 48 hours
- mon 10:00 You start a new session from your laptop. Worker creates
SessionDO named “session-abc-123.” Cloudflare boots an instance in Amsterdam (closest healthy colo). DO runs migrations (version 0 → 4), creates the messages table, accepts WS #1.
- mon 10:05 A flurry of messages. DO writes each to SQLite, relays tokens to the browser. Active.
- mon 10:45 You close the laptop. WS #1 dies. DO keeps WS #2 alive while Claude finishes a response; logs it to SQLite; WS #2 goes idle.
- mon 11:45 60 minutes of no traffic. Cloudflare evicts the DO instance to free memory. The Amsterdam box drops it; SQLite file is preserved.
- tue 09:00 You open the app from your phone. Worker asks for DO “session-abc-123.” Cloudflare boots a new instance — maybe in Dublin this time, different physical box. It reads SQLite; runs the migration loop (nothing new to apply, version matches); accepts the WS #1 from the phone.
- tue 09:00 DO replays history to the phone: every message, every tool call, every delta from yesterday in the order they happened. The phone UI renders the full transcript.
- tue 09:05 You type a follow-up. DO opens WS #2 to cc-gateway using the saved sessionId. Claude resumes — same workspace, same git worktree, same working memory as yesterday.
- tue 14:00 A new schema migration (version 5: add a
latency_ms column) ships. Every DO wake-up from now on runs the migration loop; sessions upgrade individually the next time each is used.
Common mistakes
- Treating a DO's in-memory state as persistent. Instance variables vanish on eviction. Only what you wrote to SQLite survives. If it matters beyond the next request, it goes to SQLite.
- Forgetting to index by the actual query pattern. SessionDO's SQLite holds potentially thousands of messages. Queries without indexes get slow fast. Add an index on whatever you
WHERE-filter by.
- Non-idempotent migrations.
INSERT INTO settings VALUES (1, 'default') in a migration is a bomb — every wake-up re-inserts. Use INSERT OR IGNORE or check existence first.
- Holding references to DOs across requests. A Worker cannot “keep a handle” to a DO between invocations. Every request re-acquires by name (
env.SessionDO.idFromName(...)). Cheap; not a real concern, but expect to see this pattern everywhere.
- Assuming DOs never fail. They're remarkably reliable, but deploys, cold boots, and rare infra issues can briefly 500. Write code that retries rather than assumes.
- Putting too much in one DO. A single DO is single-threaded globally. If every user's data goes into one “GlobalStateDO,” you've just reinvented a database with a single writer. Use one DO per entity.