One fact decides the whole design
A Durable Object is a named, single-threaded actor with its own storage, and Cloudflare routes every request for a given name to exactly one instance anywhere in the world. That is what makes it useful: no locks, no optimistic concurrency, no distributed consensus for the thing you are editing. Inside the object, operations are ordered because there is only one thread.
It is also the constraint. Single-threaded means one turn at a time. So the only real design decision is what a name maps to — and that decision sets your concurrency ceiling permanently.
We run two object types. Chat state is keyed by conversation. Operator state is keyed by organisation.
// one object per conversation — thousands of small actors
const chat = env.ConversationAgent.getByName(conversationId);
// one object per org — the operators' hub
const inbox = env.InboxAgent.getByName(orgId);Why one object per org would serialise
Suppose chat state were keyed by organisation instead. Every visitor on every page of that customer's site would route into one single-threaded object, and one AI turn is not a fast operation: embed the question, search the vector store, rerank the shortlist, then stream a generation. That is seconds of wall clock, most of it waiting on a network call.
During those seconds the object is the only place that org's traffic can be handled. Two hundred concurrent visitors become a queue two hundred deep, and the customer experiences your product as a chat box that takes a minute to say hello. Worse, the failure is invisible in development: one developer testing one conversation sees no contention at all.
Conversation granularity inverts that. A thousand concurrent visitors are a thousand objects, each single-threaded and each busy with exactly one turn. Nothing queues behind anything it does not belong to. The unit of serialisation is the unit the user already understands — their own conversation, where messages are supposed to be ordered.
What the conversation object owns
The conversation object holds the state a live thread needs to be correct: the mode it is in, its status, and the messages. Mode is the interesting one, because it is what makes takeover possible without a second system.
Writes land in the object's own SQLite first. That storage is co-located with the object, so the write is immediate and strongly consistent, and the message order a visitor sees is the order the object recorded. An alarm then batches the turn out to Turso, which is the system of record for history, search, server-rendered pages and analytics.
The split follows from the read patterns. A live conversation is read by the two people in it, and they need the newest message, so it is read from the object. Past conversations are read by dashboards, filters and reports that span every conversation in an org, which no per-conversation object can answer — so those are read from Turso. Neither store is a cache of the other; they answer different questions.
- Mode ai — visitor messages run retrieval and the AI streams the reply.
- Mode human — the AI is suppressed; operator and visitor exchange messages through the same object.
- Mode ai_assist — the AI drafts, the operator sends or edits.
- Internal notes are stored on the same thread with internal visibility and are never sent to the visitor.
Hibernation is why this is affordable
A thousand objects holding a WebSocket each sounds expensive, and would be if each one had to stay resident. WebSocket hibernation is what makes the count irrelevant: the runtime evicts an idle object from memory while keeping its socket open, then reconstructs it when the next frame arrives. You are not billed for duration while nobody is typing.
That changes what the protocol should look like. Each inbound frame is billed as a request, so the socket carries meaningful events — a message, a mode change, a presence update — and not a heartbeat every few seconds. Chatty keep-alives are the one way to make a hibernating design cost more than a non-hibernating one.
It also means the object must be able to rebuild itself from storage rather than from memory. Anything held only in a field is gone after an eviction, which is a useful discipline: it forces the state that matters into storage, where it was always supposed to be.
Takeover is a state change plus one call
When an operator takes a conversation, the object flips mode to human and records who claimed it. Because the object is the single writer for that thread, there is no window where the AI and the operator both answer — the next visitor message arrives after the flip, sees human mode, and is routed to operators instead of the model.
The operator's dashboard learns about it because state is synced to connected clients, and the rest of the organisation learns about it because the conversation object calls its org's inbox object directly, object to object, by name. The inbox object broadcasts to every connected operator dashboard. If nobody is connected, it escalates out of band by push or email rather than leaving the visitor waiting on a team that has gone home.
This is the payoff for splitting the two object types. The conversation object never needs to know how many operators exist or which of them are online; it needs one stub to one well-known name.
Why the inbox object is allowed to be per-org
The rule we broke deliberately: the inbox object is one per organisation, which is exactly the shape we just argued against. It is safe because the population is different. Visitors per org are unbounded; operators per org are tens. A single-threaded object that fans out presence and inbox events to thirty dashboards is not going to be the bottleneck.
It also has to be one object, because its whole job is to be the single place a fan-out can happen from. Presence only works if one actor knows who is viewing what.
Two details in that socket are load-bearing. Presence is deduplicated by user, because one operator with four tabs is still one person — the tab with a conversation open wins. And typing is soft state with no stop event: the sender re-pings while it is typing and the receiver expires the entry after a few seconds, so a dropped frame resolves itself instead of leaving a permanent typing indicator. Nothing in the message handler is allowed to throw, either. A malformed frame from one operator's stale tab must not take down the socket the whole organisation is on.
What it costs us
Two stores means two truths to keep straight, and the discipline is remembering which question each one answers. A report that counts today's conversations reads Turso, so it trails the flush. A live thread reads the object, so it is instant. Mixing those up produces bugs that look like caching problems and are actually architecture questions.
The alternative was a single hot object per organisation with simple bookkeeping and a ceiling we would have hit at the first customer with real traffic. We would rather reason about two stores than explain a one-minute first reply.