Kitsoki is an autonomous application platform: an application is declared as data — a definition, an ontology, and a set of typed effects — and the platform runs it, observes it, and redeploys it. Because the application is data rather than a codebase wrapped around a process, we had unusual freedom in deciding where it actually executes, and we spent that freedom on Cloudflare.
This post is about one consequence of that choice: what happens to the data plane when you stop starting from a single large shared database. The companion post, The deploy is coming from inside the application, covers the other half - how the thing is built, tested and shipped.
Developing for this environment does require a shift in thinking. For people used to traditional SaaS patterns—regional high availability, row-level security, and long-lived application servers—getting the most from the edge means giving up some control and orchestration we would otherwise build and maintain ourselves. It also means real lock-in: Workers, Durable Objects, D1 and R2 are bindings, not interfaces, and there is no second source for them. We think the trade is worth it, and this post is an argument about why - including the parts that cost us something. The short version: the isolation stops depending on anyone being careful, the failure of any one part stops being everyone's failure, and most of the routine maintenance simply has nothing left to maintain.
Highly available applications are traditionally expensive. Many teams quite reasonably choose one availability region plus backup and recovery because a hot/hot design adds real operational complexity. Cloudflare supplies a global edge and managed primitives, but it also asks us to work within smaller, explicitly bounded units of state and compute.
Consistency is key
The data plane is where DevOps and engineering teams earn their grey hair: backup and restore, scale-up, version upgrades, safe rollback, and the operational cost of keeping a large shared cluster healthy. Cloudflare’s model does not make those concerns disappear; it makes the boundaries smaller and more deliberate.
Instead of starting from one very large shared database, an edge-native system can route a verified application, tenant, or session scope to the coordination unit that owns it. A Durable Object supplies that narrow ownership boundary, and this is where the section title earns itself: a Durable Object is a single-threaded actor with its own storage, so within one object you get serializable consistency for free, with no transaction isolation level to choose and no lost-update race to reason about. The reason routing by ownership works at all is that the consistency unit and the ownership unit are the same object. You are not sharding a database and hoping the boundaries line up; the boundary is the thing that gives you the guarantee.
D1 is a bounded relational store exposed through a Worker binding, while R2 holds larger immutable objects and artifacts. The key is not to pretend every problem is a separate database: it is to make ownership and routing explicit - and even better based on data contained in your session cookie.
The familiar shared-infrastructure shape concentrates availability and recovery work around one large application fleet and database:
flowchart TB
Client[Client] --> LB[Load balancer or nginx]
LB --> K8s[Kubernetes application fleet]
K8s --> Primary[Large hosted Postgres primary]
Primary --> Replica[Read replica]
Primary --> Backup[Backup and recovery pipeline]
The edge-native model instead routes a verified request to the bounded scope that owns it:
flowchart TB
Browser[Browser] --> Worker[Cloudflare Worker]
Worker --> Cookie[Validate signed cookie fields]
Cookie --> Scope[Choose application and session scope]
Scope --> DOA[Durable Object scope A]
Scope --> DOB[Durable Object scope B]
Scope --> DOC[Durable Object scope C]
Worker --> D1[D1 bounded relational data]
DOA --> StateA[Isolated object state]
DOB --> StateB[Isolated object state]
DOC --> StateC[Isolated object state]
Bounded domains instead of one shared tenant table
Splitting tenants and sessions into their own bounded domains is what makes the edge data plane practical rather than merely fashionable.
A Durable Object's SQLite store is capped at 10 GB. Read as a limit on the database, that number is disqualifying. Read as a limit on one tenant's database, it is generous - and it stops being a ceiling you approach and becomes a boundary you design against. A tenant that outgrows it has outgrown a single coordination unit, which is information rather than a surprise - though today it is information we act on by hand, because the store that would let one tenant span several units is still being built. That is the last section of this post.
The isolation is the bigger prize. In a shared multi-tenant database, tenant separation is a property of every query you will ever write: row-level security policies, a tenant_id predicate on each join, an ORM that has to be trusted to attach it, and a review process whose job is to catch the one place it was forgotten. The blast radius of a single missing predicate is every customer. When each tenant owns its own store, separation is structural. There is no cross-tenant query to get wrong, because there is no cross-tenant connection to issue - reaching another tenant's data means routing to a different object entirely, and that routing decision happens once, at the edge, against a verified session.
The bill for that arrives immediately, and it is worth naming rather than skipping. Every question that spans tenants - usage metering, billing rollups, "which customers are affected by this bug", an internal admin console, the top twenty accounts by seat count - is a GROUP BY in a shared database and a fan-out across N stores here, with no transactional snapshot across them. Our answer is the ordinary one: bounded domains own the operational truth, and cross-tenant questions are answered from an events stream landing in a separate analytical store. That is a second system, and pretending otherwise would be dishonest. What we get for it is that the second system is read-only and derived, so a bug in it cannot leak one customer's data into another's request.
The ceiling worth watching is not the storage one. A Durable Object is single-threaded, so a tenant is far more likely to run out of one thread than out of 10 GB, and that ceiling is much more outage-shaped. Placement matters for the same reason: a Worker runs everywhere, but a Durable Object lives in one place, so a Sydney user whose tenant object was created in Frankfurt is a long way from their own data. The edge is genuinely close to the user for authentication, routing, and cached reads; the stateful hop is as far away as you let it be created. Both are real constraints and both are design work, not things the platform hands you.
Isolation applies one level down too. Session scope gets its own bounded domain for the same reason: a session's working state is short-lived, high-churn, and interesting to exactly one person. Giving it an owner makes its lifecycle explicit and its cleanup a deletion rather than a retention policy.
In a shared store, every path to the data runs through the same rows, and correctness depends on a predicate being attached every single time:
flowchart TB
App[Application query] --> Tid[Attach tenant_id predicate]
Tid --> RLS[Row-level security policies]
RLS --> Rows[(All tenants, one table)]
Miss[One missing predicate] -. blast radius .-> Rows
With bounded domains, the decision is made once at the edge and there is no shared row set to reach past:
flowchart TB
Request[Request with verified session] --> Edge[Edge routing on tenant scope]
Edge --> T1[(Tenant A store)]
Edge --> T2[(Tenant B store)]
Edge --> T3[(Tenant C store)]
Backup, restore, migration and deletion all become per-tenant operations. The mechanism is there rather than bespoke: SQLite-backed Durable Objects expose point-in-time recovery through bookmarks over a 30-day window, and D1 exposes the same idea as Time Travel. Restoring one customer stops being a cluster-wide event that has to be scheduled and communicated, and a deletion request is a boundary you drop rather than a query you audit.
The boundaries get smaller; they do not get fewer, and that is the real trade. One ALTER TABLE becomes N migrations with N partial-failure states, and the failure mode is not a dramatic one - it is eleven tenants stuck on the old schema that nobody notices for a week. A fleet of bounded domains is only easier than one big database if you can answer "which tenants are on which revision, and did last night's backup verify for every one of them?" without logging in anywhere. That is precisely why the migration and the receipt for it are application-plane concerns for us rather than a runbook, which is the subject of a companion post, The deploy is coming from inside the application.
The part that is actually about security
Everything above is usually filed under architecture. It is mostly a security argument, and it is worth making that explicit rather than leaving it as a pleasant side effect.
The isolation is structural, so it does not depend on anyone being careful. A shared multi-tenant database is secure exactly as long as every query, every migration, every ad-hoc fix at two in the morning, and every ORM code path attaches the predicate. That is a control which degrades under deadline pressure, and its failure mode is silent and total: the query returns more rows, not an error. Bounded domains move the control from discipline to topology. There is no query that can span tenants because there is no connection that can, and the routing decision that picks a store happens once, at the edge, against a verified session — not in application code that a future contributor can bypass.
There is no database credential to leak, because there is no database credential. A Worker reaches D1 or a Durable Object through a binding, which is a capability handed to that specific deployment — not a connection string, not a hostname, not a password sitting in an environment variable that ends up in a log, a screenshot, or a .env someone commits. There is nothing to rotate, nothing to put in a secret manager, and nothing an attacker can exfiltrate and replay from somewhere else. The commonest way a production database is breached is simply unavailable as a move.
There is no listening port. No public Postgres endpoint, no bastion host, no VPC peering to get subtly wrong, no IP allowlist that someone widens for a contractor and never narrows again. There is also no database server to patch, no operating system under it, no TLS certificate to renew, and no minor-version upgrade window to schedule — which is the same sentence read as a maintenance argument instead of a security one. Most of the CVEs a team of our size actually loses evenings to are in software we no longer run.
Blast radius is bounded by construction. Whatever goes wrong — a bad deploy, a corrupted write, a compromised session, a runaway migration — is scoped to the coordination unit that owns it. One tenant's bad day is one tenant's bad day. There is no primary to fail over, no replica to fall behind, no cluster-wide event that has to be scheduled and communicated, and no single object whose loss takes everyone down together. Resilience and isolation turn out to be the same property viewed from two directions, and both come from the same decision to make ownership explicit.
None of this is free — the fan-out for cross-tenant reads and the N-migrations problem above are the bill. But the things it removes are the ones that actually cause incidents, and they are removed rather than mitigated.
Scale to zero, without the cold-start tax
Bounded domains also make scale to zero honest.
A single global environment has to be up 24/7 because someone is always using it. That is true of the aggregate and almost never true of any individual. Look at one customer's usage and the picture inverts: nights, weekends, holidays, and the ordinary gaps in a working day mean a given environment is likely busy well under 25% of the time. In a shared fleet that idle time is invisible - you pay for peak capacity continuously because the peaks belong to different people. Once each environment is its own bounded unit, the idle time becomes yours to reclaim, and the cost curve follows actual use rather than the union of everyone's use.
The usual objection is user experience: scale to zero means someone eventually pays a cold start. The edge answers that in three parts.
Workers are the first. They are already warm, everywhere, with startup measured in milliseconds - so the layer a user hits first is never the layer that has to boot. Authentication, routing, policy and a great many reads are served without waking anything.
Intelligent caching is the second. A large share of what a session needs is immutable or slow-moving, and R2 and the edge cache serve it directly. The environment is only woken for work that genuinely requires it.
Proactive wakes are the third, the most interesting, and the least finished - so read this one as a design we are building rather than a number we can quote. The edge sees the signals that precede real work: a session cookie validated, a login completing, a page loading, a document opened. Those signals are not equivalent, and treating them as though they were is how this idea fails. Login to first real request buys seconds; page load to first authenticated fetch on a warm SPA can be under 200ms, which is not enough to hide a container start. The signals worth acting on are the early, coarse ones.
The other half of the honesty is that a wake you did not need is billed compute, and speculative warming trades directly against the idle savings the previous paragraph just claimed. That makes this an economic question with an empirical answer - what fraction of wakes are followed by real work, and how long is the gap - not an architectural one we can assert our way through. What the edge does give us that a load balancer does not is the intent signal itself, early and already authenticated. It is also a problem we would rather delete than optimise, and the companion post ends with how. The startup cost does not disappear; the bet is that it can be moved off the critical path more often than it is wasted.
sequenceDiagram
participant U as User
participant W as Worker at the edge
participant C as Cache and R2
participant E as Tenant environment
U->>W: arrives, session validated
W->>C: read cached and immutable state
C-->>U: served warm, nothing woken
W-)E: intent signal, start warming
Note over U,C: user reads the screen
Note over E: environment starting in the background
U->>W: first request needing compute
W->>E: forward
E-->>U: already warm
Past 10 GB
The boundary this post has been designing against is also the one we most want to move.
The goal is to get past it without giving up bounded domains. The answer we are building is a PostgreSQL-compatible store inspired by CockroachDB, targeting the Cloudflare architecture directly: distributed, range-based, and composed of the same bounded units this post has been describing, so a tenant that outgrows one coordination unit spans several without the application learning anything new. That is also the answer to the thread ceiling above: once a tenant's data can span units, so can the work on it. PostgreSQL compatibility matters because it keeps the door open - to existing tooling, to existing knowledge, and to running the same application against a conventional database when that is the right choice.
The primitives the edge gives you are smaller and more explicit than the ones a traditional stack hands you, and the work is in building up from them rather than emulating what you left behind. That is as true of the delivery side as it is of the data side - which is where the companion post picks up.