< blog

One DO per user

2026-03-26 · ZERO

Most apps funnel every user's data into one shared database. Connection pools, row-level security, N+1 queries, migration downtime. What if each user just got their own database?

On Cloudflare, that's exactly what Durable Objects with embedded SQLite give you. InboxKit uses this pattern: every inbox is a separate DO with its own SQLite instance.

Request Worker hash key, look up KV KV: hash → DO ID InboxDO (user A) SQLite: messages, metadata auto-migrates on wake InboxDO (user B) SQLite: messages, metadata auto-migrates on wake InboxDO (user C) SQLite: messages, metadata auto-migrates on wake ...

Three layers, zero shared state

The architecture has three parts:

KV stores two mappings: handle → DO_ID for email routing, and apiKeyHash → DO_ID for auth. Both are one-read lookups. KV is globally replicated and eventually consistent, which is perfect for read-heavy auth checks.

The Worker handles HTTP requests and inbound email. It hashes the API key, reads KV, gets back a DO ID, and forwards the request to the right Durable Object. The Worker itself is stateless.

Durable Objects are the user's data store. Each DO has embedded SQLite that stores messages, metadata, and inbox settings. When a DO wakes up, it runs migrations in transactionSync before handling any request. Schema changes roll out per-DO, not all-at-once.

What you get for free

Isolation. User A's data is physically separate from User B's. No row-level security, no tenant columns, no "WHERE user_id = ?" on every query. The isolation is structural.

No connection pool. Each DO talks to its own embedded SQLite. No shared connection pool, no "too many connections" errors, no PgBouncer.

Incremental migrations. DOs migrate on wake-up. Deploy a new migration, and each DO picks it up next time it handles a request. No maintenance window, no "migrate 10 million rows" downtime.

Natural scaling. 100 users means 100 independent SQLite instances. 100,000 means 100,000. Cloudflare handles the scheduling. You never think about sharding.

The tradeoff

You can't join across users. No "SELECT all messages sent today across all inboxes." If you need cross-user analytics, you'll need a separate aggregation layer.

For InboxKit, that's fine. Every API call is scoped to one inbox. The auth lookup already routes you to the right DO, and everything after that is local.


juanibiapina/inboxkit