A database per household, and what it costs
Physical separation per tenant buys a blast radius of one family. It also turns every routine job into a fan-out, and moves the wall somewhere surprising.
Engineering › A database per household
Most multi-tenant products put every customer in one database and separate them with a column. It is the default for good reasons: one schema to migrate, one connection pool, cheap cross-tenant queries, and a decade of tooling that assumes exactly that shape.
We did the other thing. Every household on Chickadee Bandit has its own physical database, created the moment the household is registered. This post is the argument for that and — more usefully — the bill, because we have now been paying it for long enough to know what is on it.
What it buys, stated narrowly
The honest version of the benefit is not "your data is isolated," which every product says. It is this: the blast radius of a bug in our data-access layer is one household.
That matters here more than in most products because of what we let run inside a household. Third-party apps execute SQL against the family's tables, and the hub rewrites every statement to enforce access rules. That rewriting is careful, adversarially tested, and it is still the kind of code where a subtle mistake is a cross-tenant leak in a shared-database design. With one database per household, the worst case is one family seeing its own rows in the wrong order.
It also means a household's data has a physical extent. When someone asks us to delete their account, there is an object to destroy, not a `WHERE` clause to trust.
Bindings do not scale to tenants
The first thing this costs is the fast path.
On Cloudflare, a Worker normally talks to a database through a *binding* — a handle declared in configuration at deploy time. Bindings are static. They cannot represent thousands of databases created after the deploy, one per customer, so we cannot use them for tenant data at all. Each household's database is addressed by its own identifier over the D1 Management API instead.
Which means every single query is an outbound HTTPS request, where a binding would have been an in-process call. That is the central trade of this architecture and there is no clever way around it. It shapes everything downstream: request budgets, why we count round trips obsessively, why the query-plan tooling exists, why a page that does ten reads is a page we investigate.
We do keep a bounded cache of household-to-database identifiers in each worker isolate, because otherwise every request pays a lookup in the shared registry first. Bounded, deliberately: an isolate is long-lived and serves whatever tenants route to it, so an unbounded map is a slow leak proportional to the size of the whole fleet rather than to anything about the request in front of it.
Inside one household, a prefix is enough
Within a household's database, the installed apps are separated from each other by a table-name convention — an app's tables are all prefixed with its id — rather than by database-level schemas or roles.
That sounds weak and is the correct trade given the layer above it. The expensive, genuinely dangerous isolation boundary is between families, and that one is physical. Between apps in the same family, the threat model is different: the household chose to install both, the data is theirs in both cases, and what actually needs enforcing is which *rows* an app may touch — which is enforced by rewriting the SQL, not by the database's permission system.
The prefix rule is not a naming convention that everyone politely follows, either. It is asserted on the statement the app submitted *and* re-asserted on the statement that will actually execute, after the access-rule rewriting and the encryption pass have each taken the query apart and put it back together. Whichever of those decompositions leaks, the scope rule is checked again at the point it can no longer be bypassed.
Every routine job becomes a fan-out
Here is the part that surprised us.
In a shared database, "expire everything older than thirty days" is one statement. Here it is one HTTPS round trip per household, fanned out at a fixed concurrency, against a budget. Nightly retention, activity pruning, audit trimming, notification sweeps — every one of them is that shape.
So we ended up with a small discipline for any job that touches the fleet, and it has three parts:
- **Scope.** Visit only the households that can possibly have work. We keep inverted indexes in the shared control-plane database — which households have an app declaring a given feature — so a sweep visits adopters rather than everyone.
- **Ceiling.** Every sweep declares what it costs per household and draws from one budget. Individually bounded sweeps that never looked at each other once added up to six times the ceiling they were each respecting.
- **Coverage.** A sweep must be able to say what fraction of the estate it actually reached, not just that it finished.
One small thing worth stealing: estate iteration is keyset-paginated, never offset-paginated, and never `SELECT id FROM households`. The naive version pulled the entire table into worker memory once per sweep per run — an allocation that grows with the platform and has nothing to do with the work being done.
The wall is not where we expected it
We assumed the ceiling on this architecture would be the database count — some account-level quota on how many we can have.
It is not. It is the arithmetic of windowed sweeps, and the framing that made it obvious is worth quoting more or less as we wrote it internally: *a rotating cursor promises to cover the estate eventually; a retention window promises to cover it by when.* Divide one by the other and every windowed sweep has a platform size past which its promise is arithmetically impossible — with every signal on the nightly dashboard still green, because each individual run completed fine.
So the binding constraint is not storage. It is whichever sweep has the tightest window and the largest per-household cost — ours is app data retention, whose thirty-day promise becomes unkeepable well before any other sweep's does, and long before any storage quota is in sight. What matters is not our particular number but that there *is* one, that it is a division rather than a judgement call, and that averaging across all the sweeps hides the only one that binds.
Two things we would do again about that. First, the ceiling is *computed in a test* from the real cost declarations and the real budget, not written in a document. Documents do not notice when a sweep gets more expensive. Second, having that test is what revealed the number we had been quoting was badly out of date — it had been asserting a ceiling roughly a tenth of the real one, which is an error in the comfortable direction and therefore the kind nobody investigates.
The escape hatches, in the order we would use them, are: split one scheduled job into several, raise the frequency, then move the work onto queues — and for genuinely new work, do not fan out at all. Put it in a per-household durable object with its own timer, where the cost is proportional to the households that actually use the feature and there is no shared invocation budget to run out of.
Nothing reaches into another household's database
Households that need to share — separated parents coordinating across two homes, a committee, a group of neighbours — get a *shared space*, which is itself a tenant: its own row, its own database, its own members.
The rule that keeps this comprehensible is a non-goal rather than a feature. A space never reads a home household's database and a home household never reads a space's. Data is shared by *putting it in the space*, not by granting reach-in. Every cross-household feature in the product is built on moving data into a tenant both parties belong to.
It is not free. A space has a sponsor — the household paying for it — and some accounting has to resolve through that sponsor's own database, which makes a sponsor of several spaces a hot shared dependency for all of their write traffic. That is a real cost and we would rather have it than a query that spans two families' databases.
The part that does not shard
One database is shared by everybody: the control plane. Routing, identity, the registry of which household lives where, the inverted indexes that make sweeps cheap.
It is small and its reads are fast, and it is also the one piece of this design with no sharding story. Everything else scales by adding tenants, which adds databases. This grows with total platform size and cannot be split by tenant, which makes it the long-term ceiling with no designed escape hatch. We know that. It is written down as such rather than discovered later, which is the most we can honestly claim about it today.
The bill we have not paid yet
This design is specific to one platform, and it is worth being plain about how specific.
The provisioning path talks to a vendor-specific management API. The registry maps households to that vendor's database identifiers. The fan-out budget is denominated in that vendor's per-invocation subrequest limit. The scheduled jobs are that vendor's cron triggers, and the per-household timer pattern we recommend for new work is that vendor's durable objects. None of those is a thin adapter over a generic idea; each one is shaped by the specific limit it exists to respect.
So porting this is not swapping a driver. The per-household SQLite-ish store has obvious equivalents elsewhere and would move without much drama. The control plane around it — provisioning, teardown, the budget arithmetic, the sweep scheduler — is most of the work in this post, and essentially all of it would be rewritten.
We think that is the right trade for a small team shipping a product rather than a platform, because the alternative is an abstraction layer written against exactly one implementation, which is a portability story people tell themselves rather than one they have. But it is a real liability rather than a rounding error, and anyone weighing this architecture should price it as one.
If you are considering this
Three things we would want to have been told.
**Provisioning and teardown become load-bearing jobs, not formalities.** The nastiest defect in this area was not a leak; it was the reverse. Long-lived bearer URLs — a calendar feed someone subscribed to years ago, a wall display polling every sixty seconds — resolve to a household id *before* any tenant check runs. For a deleted household, that used to land in the provisioning path and mint a brand new empty database: an orphan against a real quota, created by an unauthenticated request, that no sweep could see because no household row pointed at it. The fix is that provisioning refuses to create a database for a household that is not registered. Write that refusal on day one.
**Decide how a query is routed before you build anything on top.** We considered putting the per-household store in durable objects with embedded SQLite, and rejected it: the synchronous co-located queries are a real edge, and the single-threaded, single-region ceiling outweighs it for a family hub where the household is the unit of concurrency.
**Count tenants, not customers.** A shared space is a full tenant. Co-parenting families produce roughly one and a half tenants per customer household, so every capacity figure computed in customers is optimistic by a factor nobody notices until a sweep starts missing its window.
Read next
Written at Chickadee Bandit, a private hub for running a household — the thing all of this is load-bearing for. More engineering posts.