One tenant, one data island?
D1, Durable Objects, PostgreSQL schemas, and MySQL databases solve tenant isolation in four very different ways. The invoice is only one of them.
Consider a multi-tenant application with a small shared control plane. It knows which tenants exist, their domains, status, plan, data location, and schema version. Business data lives in a separate tenant data plane.
request
→ Worker: resolve tenant, authenticate, validate
→ control plane: tenant → data location + schema version
→ tenant data plane
├─ D1 database
├─ Durable Object with SQLite
├─ PostgreSQL schema
└─ MySQL database
→ Worker: render HTML or return JSON
The frontend already runs on Cloudflare Workers. If the backend only receives an API request, applies business rules, talks to storage, and returns a response, it can run in the Worker too. The backend has not disappeared. It has simply stopped renting an application server all month.
The interesting decision is what sits behind it. Cost matters, but implementation speed, isolation, migrations, reliability, backup, and disaster recovery will be felt long after the first invoice has been forgotten.
The four models
| Model | Tenant boundary | Shared underneath | Natural strength |
|---|---|---|---|
| D1 per tenant | Separate SQLite database | Cloudflare D1 service and account limits | Simple relational CRUD with strong storage separation |
| Durable Object per tenant | Separate actor, execution context, and SQLite database | Worker, namespace, and Cloudflare platform | Ordered state changes, coordination, WebSockets, and alarms |
| PostgreSQL schema per tenant | Separate schema inside one database | Compute, memory, WAL, connection pool, and database | Rich SQL and mature tooling with one operational cluster |
| MySQL database per tenant | Separate database inside one server or cluster | Compute, buffer pool, binary log, connections, and server | Familiar logical separation with database-level grants |
There is a naming trap here. In MySQL, schema and database are synonyms. A MySQL database per tenant is therefore much closer to a PostgreSQL schema per tenant than to a D1 database per tenant. It is a namespace in a shared server, not a private database engine with its own compute and failure domain.
The short comparison
| Concern | D1 database | Durable Object | PostgreSQL schema | MySQL database |
|---|---|---|---|---|
| Initial implementation | Easy SQL; per-tenant binding design appears early | Fast when the actor model fits; otherwise more bespoke code | Fastest with conventional drivers, ORMs, and one pool | Similarly conventional; select or qualify the tenant database |
| Provisioning | Create database, migrate, register, bind or assign a shard | Derive object ID; initialise storage on first use | Create schema, owner or role, grants, migrate | Create database, grants, migrate |
| Isolation | Separate storage database | Separate storage and execution unit | Logical namespace and privileges in one database | Logical namespace and grants in one server |
| Noisy neighbour | Mostly contained to one single-threaded database | Contained to one object unless it overloads shared entry points | Shared CPU, I/O, cache, WAL, locks, and connection limits | Shared CPU, I/O, buffer pool, binary log, and connections |
| Cross-tenant transaction | No atomic transaction across databases | No atomic transaction across objects | Yes, across schemas in the same database | Yes, across databases on the same server when using transactional engines |
| Cross-tenant reporting | Awkward; aggregate elsewhere | Awkward; build an index or analytical pipeline | Natural SQL across schemas, though large schema counts hurt | Natural qualified queries across databases on the same server |
| Per-tenant backup | Excellent built-in PITR boundary and SQL export | PITR per object; bulk export needs application tooling | Logical schema dump available; physical recovery is usually cluster-wide | Schema/database dump available; physical recovery is usually server-wide |
| Portability | SQLite data is familiar; platform API and bindings are not | High Cloudflare coupling: actor lifecycle, RPC, alarms, storage API | Strong ecosystem and broad provider choice | Strong ecosystem and broad provider choice |
| Hard tenant data limit | 10 GB per paid database | 10 GB per paid SQLite-backed object | Provider and cluster limits | Provider and server limits |
1. Routing and connection management
D1: the control plane resolves a tenant to a D1 database. A normal Worker accesses that database through a binding. Cloudflare allows 50,000 D1 databases on a paid account, but one Worker script can bind approximately 5,000 resources. That makes routing a deployment concern: shard tenants across Workers, introduce Workers for Platforms, or choose a shared D1 design instead. The 50,000-database limit is real; the single-Worker path to all 50,000 is not.
Durable Objects: routing is the cleanest of the four. Derive a stable object ID from the tenant ID and obtain its stub. There is no database to pre-create and no list of tenant bindings in the Worker deployment. The first request creates the object’s execution context and storage. That simplicity is one of the strongest arguments for the model.
PostgreSQL schemas: every tenant can use the same connection pool because all schemas live in one database. Code either fully qualifies table names or sets a carefully controlled search_path for the transaction. Schema names are SQL identifiers, not values, so they cannot be handled like an ordinary prepared-statement parameter. Permissions and search_path must be designed defensively; PostgreSQL explicitly warns that writable schemas in the search path can allow one user to influence another user’s queries.
MySQL databases: tenants can also share a server-level connection pool. The application selects a database with USE, chooses it in the connection settings, or qualifies objects as tenant_database.table. Database-level grants are straightforward. As with PostgreSQL, a connection returned to the pool must not retain the previous tenant’s session state.
2. Provisioning and time to first tenant
PostgreSQL and MySQL are the quickest to understand with a conventional application stack. Provisioning is ordinary DDL plus roles or grants. Existing migration tools, database clients, ORMs, admin interfaces, and monitoring systems already know what they are looking at.
D1 also offers familiar SQL, but database-per-tenant provisioning is a control-plane workflow rather than a single DDL statement in the application connection: create the database through Cloudflare, apply migrations, register it, and make it reachable by the correct Worker. The process is not difficult, but it must be idempotent and resumable. A half-created tenant is still a tenant support ticket.
Durable Objects need the least explicit provisioning. The object can create its tables on first use and record its own schema version. That is excellent for a large sparse tenant population. The trade-off is that ordinary database administration becomes application behaviour. The platform will not invent a tenant catalogue, export format, migration dashboard, or fleet health report for us.
3. Schema migrations
Database-per-tenant and schema-per-tenant designs turn one migration into a fleet operation. The application must know which tenants are on which version, retry safely, limit concurrency, record failures, and remain compatible with at least two schema versions during a rolling upgrade.
D1: run the migration against every database. Cloudflare tracks D1 migrations per database, but the orchestration across thousands of databases is ours.
Durable Objects: namespace migrations create or rename Durable Object classes, but they do not replace application-level SQL migrations inside every object. A common pattern is lazy migration when an object wakes. It avoids a deployment storm, but the fleet may remain on mixed versions for a long time.
PostgreSQL schemas and MySQL databases: the catalogue makes tenants easy to enumerate and conventional tools can run the same migration repeatedly. That is operationally familiar, but thousands of schemas still mean thousands of DDL operations, locks, catalogue entries, and opportunities for version drift.
A shared-schema design with a tenant_id column wins this category because there is only one schema to migrate. It also gives up the structural isolation we are trying to compare. Architecture remains annoyingly consistent about charging for whatever advantage we request.
4. Transactions, consistency, and concurrency
D1 and SQLite-backed Durable Objects are both single-threaded at the individual database or object level. That can be a useful form of isolation: one tenant’s write burst does not lock tables belonging to another tenant. It also caps the throughput of a single hot tenant unless its data is sharded further.
A Durable Object goes further by serialising events for the object. Storage is private, transactional, and strongly consistent. For bookings, counters, collaborative rooms, subscription state, or anything that benefits from one authority making ordered decisions, this is not a database workaround. It is the architecture.
D1 is the more conventional choice when requests are independent relational CRUD. Global read replicas can reduce read latency, but they are asynchronous. Applications that need sequential consistency must use the Sessions API and carry bookmarks. Writes still go to the primary.
PostgreSQL and MySQL support many concurrent transactions and much higher single-tenant headroom on suitable hardware. Schemas or databases on the same cluster can participate in one transaction, which is useful for control-plane updates and cross-tenant administration. The price is a shared resource pool: one bad query, connection leak, checkpoint, table lock, or I/O-heavy tenant can affect everyone.
5. Latency and data placement
Running code at the edge does not make state omnipresent. The request still travels to wherever writes are coordinated.
D1 has a primary location and optional global read replication. Read-heavy applications can benefit considerably, provided their consistency requirements match the Sessions API. D1 supports location hints and EU, US, and FedRAMP jurisdiction restrictions at database creation time.
A Durable Object is placed near its first meaningful request or according to a best-effort location hint. It does not currently relocate after creation. That is excellent for a tenant concentrated in one region and less attractive when the tenant’s users later move or are globally distributed. Jurisdiction-restricted namespaces can keep compute and storage within supported regions.
PostgreSQL and MySQL are normally regional. From Workers, Hyperdrive can pool connections and reduce setup latency; Worker Placement can also move execution closer to the database. Read replicas and multi-region products are available from many providers, but their consistency, failover, and price vary. They must be selected deliberately rather than inferred from the word “managed”.
6. Isolation and the noisy-neighbour problem
D1 provides the clearest data boundary: the wrong query cannot accidentally omit tenant_id and read another tenant because that tenant’s tables are in a different database. A routing or authorisation bug can still select the wrong binding, so isolation has moved rather than become magical.
Durable Objects add an execution boundary. One object cannot directly read another object’s private storage. This is strong isolation for both state and coordination, although shared Worker entry points, account limits, and downstream services still exist.
PostgreSQL schemas can have separate owners and USAGE, CREATE, and table privileges. MySQL can grant privileges per database. Both are meaningful security controls, but both share a process or cluster. Neither schema nor database namespaces reserve CPU, memory, I/O, or connections for a tenant. Workload isolation requires resource governance outside the namespace.
7. Backup and restoring one tenant
D1: Time Travel is always on and restores a database to any minute within the previous 30 days on the paid plan. The restore overwrites that database in place and cancels in-flight work. It can be undone using the previous bookmark. For longer retention or an independent copy, D1 can export SQL and a Workflow can place exports in R2. This is a very good match for database-per-tenant recovery.
Durable Objects: SQLite-backed objects also expose 30-day point-in-time recovery for the whole object database, including SQL and KV data. The recovery boundary is exactly one tenant if the object boundary is one tenant. The awkward part is fleet operations: object storage is private and there is no ordinary database server to which a backup product connects. Long-term export, inventory, and bulk recovery need application-level tooling.
PostgreSQL: pg_dump --schema can create a logical dump of one tenant schema. That is convenient, but schema-only selection does not automatically include every non-schema object, and dependencies on shared extensions, roles, functions, or large objects need attention. Physical backups plus archived WAL provide mature point-in-time recovery, usually for the entire cluster. Recovering one schema to an earlier time often means restoring the cluster to a temporary environment and extracting that schema.
MySQL: MySQL Shell can dump selected databases and load them again, which maps neatly to a tenant database. Binary logs support point-in-time recovery after a base backup. As with PostgreSQL, physical recovery normally reconstructs the server or cluster; recovering one tenant often uses a logical dump or a temporary recovery environment.
8. Disaster recovery is not a backup checkbox
A backup answers “can the data exist again?” Disaster recovery also asks where it will run, how traffic moves there, how long it takes, how much data can be lost, and whether the restore procedure has ever survived contact with a stopwatch.
| Question | D1 | Durable Objects | PostgreSQL / MySQL cluster |
|---|---|---|---|
| Smallest restore unit | One tenant database | One tenant object | Logical tenant dump; physical restore is normally cluster-wide |
| Built-in PITR window | 30 days paid | 30 days for SQLite-backed objects | Provider or self-managed WAL / binary-log retention |
| Independent long-term copy | Schedule SQL exports outside D1 | Build an export path outside object storage | Standard backup products and object-storage archives |
| Regional failover control | Platform-managed; limited user control | Platform-managed placement; objects do not currently relocate | Mature replicas and failover, but we must configure and test them |
| Fleet-wide recovery | Many small restores and binding reconciliation | Many object restores plus control-plane reconciliation | Restore or promote one cluster, then validate all tenants |
D1 and Durable Objects reduce infrastructure work, but they also reduce the levers available during an incident. PostgreSQL and MySQL expose more conventional replication, promotion, and restore patterns, but someone must own them. Managed does not mean rehearsed.
Whatever the choice, the design needs explicit targets:
- RPO: how much tenant data may be lost — zero, one minute, one hour?
- RTO: how long may one tenant, a region, or the whole service remain unavailable?
- Restore scope: one tenant, a group of tenants, or the entire platform?
- Retention: is 30 days sufficient, and are immutable off-platform copies required?
- Proof: when was the last automated restore tested against a real application build?
9. Observability and support
PostgreSQL and MySQL have the deepest operational ecosystems: slow-query logs, query plans, connection metrics, lock inspection, replication lag, backup catalogues, and tools familiar to database teams. The difficulty is attributing shared resource consumption to one schema or database and preventing one tenant from exhausting the cluster.
D1 returns rows-read, rows-written, duration, region, and replica metadata, and metrics can be viewed per database. That makes per-tenant cost attribution attractive — until there are 50,000 database dashboards. The control plane needs to aggregate fleet health rather than expecting a human to browse it.
Durable Objects expose request, duration, and storage behaviour, but troubleshooting spans Worker routing, object lifecycle, object-local storage, and possibly alarms or WebSockets. Correlation IDs and tenant-aware logs are not optional. Neither is a way to inspect an object without inventing an unauthorised support backdoor.
10. Local development and testing
PostgreSQL and MySQL win on familiarity. A local container behaves like the production engine, migrations run through established tools, and integration tests can inspect the database directly. Differences still appear when production uses managed proxies, replicas, or extensions unavailable locally.
D1 and Durable Objects have good local Worker tooling, but a local SQLite file does not reproduce every property of the hosted service. D1 replication, regional latency, account limits, Durable Object placement, eviction, and PITR behaviour require remote or deliberately simulated tests.
A serious proof should test more than CRUD: concurrent writes, mixed schema versions, a failed migration, an accidental delete, restoration of one tenant, a noisy tenant, expired credentials, and a regional dependency failure.
11. Portability and lock-in
PostgreSQL and MySQL provide the easiest provider exit. Managed-service features can still create lock-in, but the core database, dump formats, drivers, and expertise are widely available.
D1 stores SQLite data and can export SQL, which is a useful escape hatch. The application still depends on D1 bindings, Cloudflare provisioning APIs, limits, consistency behaviour, and deployment topology. Moving the rows is easier than moving the operating model.
Durable Objects create the strongest coupling because code and state share the actor model: deterministic IDs, object lifecycle, RPC, alarms, WebSockets, private storage, and per-object serialisation. That coupling is justified when those features remove difficult distributed-systems code. It is less attractive when the object is only pretending to be a database connection.
12. Cost, finally
Cost deserves a calculation, just not the first vote. Before counting anything, we need to stop using request, query, and row as though they were interchangeable.
- A static asset request asks for HTML, CSS, JavaScript, an icon, or an image. With Workers Static Assets, a matching asset can be served without invoking Worker code and is free. Routing it through the Worker changes that.
- A dynamic Worker request is one HTTP request that invokes our Worker: load the dashboard data, save a form, fetch a report, or return an authenticated image. This is the unit used for Workers request billing.
- A SQL query is one statement executed by that request. One Worker request may execute no SQL, one query, or a dozen queries.
- A row read is a row scanned by D1, not a query and not necessarily a row returned to the user. An indexed lookup may read one row; an unindexed filter may scan thousands.
- A Durable Object request is another metered operation. If the Worker calls one tenant object once, the same user action normally creates one Worker request and one Durable Object request, plus any rows read or written inside its SQLite storage.
A concrete application, not an imaginary request
Take a small portal like the family portal I already run. Opening the application makes two dynamic API requests: one for the session and one for the bootstrap data. The bootstrap is still only one HTTP request, but it performs roughly ten SQL statements to assemble circles, posts, comments, reactions, events, albums, people, members, invitations, and meetings. Changing a tab that is already loaded may make zero requests. Adding a reaction makes one write request and then another request to refresh the bootstrap data. Opening a private album can add one metadata request plus a request for every protected thumbnail.
So “2,000 requests” does not mean 2,000 dashboard views. In a heavily batched server-rendered application it might be close to 2,000 page interactions. In a chatty single-page application, 2,000 requests might represent only 100–200 short sessions. Architecture decides the multiplier.
Background work counts, even when nobody opens the website
A QA application makes the frontend an unusually poor proxy for cost. A user may open a dashboard twice a day only to inspect work performed continuously in the background. The billing boundary is not “did a browser call us?” It is “which platform services did the system use?”
browser
→ API Worker: submit a QA run or read its status
→ Queue or Workflow: make the work durable
→ background Worker
├─ call external APIs
├─ evaluate responses
├─ read and write D1 or Durable Object storage
└─ persist the result
→ API Worker: return the result to the dashboard
- Cron: every scheduled invocation is a Worker request and consumes Worker CPU. An hourly schedule produces 720 invocations in a 30-day month, whether or not anybody visits the site.
- Queues: queue operations are billed separately. A successfully delivered message normally creates three operations — write, read, and delete. The consumer Worker invocation also uses the Workers request and CPU meters; batching ten messages can turn ten jobs into one consumer invocation, but it does not turn ten queue messages into one queue operation.
- Workflows: starting a Workflow is an invocation. CPU, persisted state, and executed steps are metered. Calls made from inside the Workflow are subrequests rather than additional Workflow invocations.
- External APIs: outbound
fetch()calls are subrequests. Cloudflare does not bill each one as another Worker request, although limits apply and the API provider may charge for every call. Waiting on network I/O does not count as Worker CPU time. - D1: every background query contributes its actual rows read and written. It makes no difference whether the query came from a webpage request, Cron, Queue consumer, Workflow, Wrangler, or the dashboard.
- Durable Objects: every RPC session, HTTP call, alarm, or billed group of WebSocket messages contributes Durable Object requests. SQLite rows, storage, and active duration are metered as well.
Suppose one million QA jobs run in a month. Each job makes four external API calls, executes twelve SQL statements, scans 200 rows, writes eight rows, and stores a 20 KB result. The frontend traffic may still be tiny, but the background ledger looks like this:
| Meter | For 1 million QA jobs | What it means |
|---|---|---|
| External API calls | 4 million | Worker subrequests; not four million extra Worker request charges, but possibly four million vendor charges |
| SQL statements | 12 million | Useful for capacity and latency; D1 does not bill the statement count itself |
| D1 / SQLite rows read | 200 million | The database billing meter, based on rows actually scanned |
| D1 / SQLite rows written | 8 million | Before additional index writes, retries, logs, and status updates |
| Queue operations | About 3 million | One write, one read, and one delete per successfully delivered message |
| Queue consumer invocations | About 100,000 with batches of ten | Worker requests and CPU; retries increase the number |
| Workflow invocations | Up to 1 million | If every QA job is a separate Workflow; steps and state are additional meters |
| Durable Object calls | 1 million if called once per job | Separate from the originating Worker or Workflow invocation |
| Result storage | 20 GB before overhead | Twenty kilobytes per retained result; retention policy determines GB-month |
The example is deliberately transparent rather than predictive. A real QA run may make four API calls or four thousand. The estimate must start with jobs × operations per job × runs per month, then apply batching, retries, retention, and the measured rows scanned by each query.
The interactive profiles below therefore start with people and activity. They model the frontend and ordinary API only; a QA-style background ledger must be added separately. Every figure covers one 30-day billing month, is expressed in US dollars before tax, and assumes 50,000 tenants. For the arithmetic, an active user-day means one person using the application on one day; several sessions on that day are folded into the request allowance.
| Profile | Dormant | Working | Busy |
|---|---|---|---|
| Example tenant behaviour | One user opens the app twice a month | Five users are active on ten days | Twenty users are active on twenty days |
| Dynamic requests per visit / active user-day | 10 per visit | 20 per active day | 50 per active day |
| Dynamic requests per tenant per month | 20 | 1,000 | 20,000 |
| Average requests per tenant per calendar day | 0.67 | 33.33 | 666.67 |
| Total dynamic requests per month | 1 million | 50 million | 1 billion |
| Average requests per second, whole platform | 0.39 | 19.29 | 385.80 |
| Assumed SQL statements per dynamic request | 4 average | 4 average | 4 average |
| Assumed rows scanned per dynamic request | 50 average | 50 average | 50 average |
| Assumed logical rows written | 1 per 10 requests | 1 per 10 requests | 1 per 10 requests |
| Worker CPU per month at 5 ms/request | 5 million ms | 250 million ms | 5 billion ms |
| Rows read per month | 50 million | 2.5 billion | 50 billion |
| Logical rows written per month | 100,000 | 5 million | 100 million |
| Average stored data per tenant | 80 KB | 1 MB | 10 MB |
| Average stored data, whole platform | 4 GB-month | 50 GB-month | 500 GB-month |
Four SQL statements and 50 rows scanned are blended averages, not properties of D1 or PostgreSQL. A login check might execute one indexed query and read one row. A dashboard bootstrap may execute ten queries and scan hundreds. The only honest production number comes from instrumentation per endpoint and per business action.
| Estimated bill for one month | Dormant | Working | Busy |
|---|---|---|---|
| Workers plan, requests, and CPU | $5.00 | $21.40 | $401.40 |
| D1 rows read and written | $0.00 | $0.00 | $75.00 |
| D1 storage | $0.00 | $33.75 | $371.25 |
| Workers + D1 total | $5.00/month | $55.15/month | $847.65/month |
| Durable Object requests | $0.00 | $7.35 | $149.85 |
| Durable Object duration at 10 ms/call | $0.00 | $0.00 | $12.50 |
| Durable Object rows read and written | $0.00 | $0.00 | $75.00 |
| Durable Object storage | $0.00 | $9.00 | $99.00 |
| Workers + Durable Objects total | $5.00/month | $37.75/month | $737.75/month |
| Supabase Pro with one Micro instance | $25.00 | $25.00 | $25.00 |
| Supabase database disk overage | $0.00 | $5.25 | $61.50 |
| Workers + shared Supabase PostgreSQL floor | at least $30.00/month | at least $51.65/month | at least $487.90/month |
The dormant profile is genuinely dormant: one person opens the application twice in a month and each visit causes ten dynamic requests. The five-dollar claim does not describe 20 requests per minute, hour, or day. The old “busy” value of 2,000 requests per tenant per month was only about 67 requests per day for an entire tenant; that is light use, not a busy business application.
D1 row charges appear in the busy profile because 50 billion rows read and 100 million rows written exceed the paid plan’s monthly allowances. Durable Object duration also becomes billable at one billion short calls. A non-hibernating object, a WebSocket workload, slower handlers, extra index writes, or inefficient scans would raise those lines further.
The PostgreSQL line is a reference floor using one Supabase Pro project, the included Micro compute credit, and 8 GB of database disk, followed by published disk overage. It does not promise that Micro compute can serve the working profile and it almost certainly cannot serve the busy one. A realistic PostgreSQL number needs a selected compute tier and measured query workload. MySQL has the same capacity-based cost shape, but no honest number exists without choosing a provider and topology.
The five-dollar result is therefore a monthly billing floor for 50,000 mostly sleeping tenants, not a weekly, daily, or annual price and certainly not a promise that 50,000 active businesses can be served for five dollars. It is mathematically possible only in the dormant profile and only for the metered resources shown. It does not solve the D1 binding topology. It excludes files, email, queues, custom domains, monitoring, longer backup retention, support, and engineering time. At larger data volumes, current D1 storage pricing dominates the estimate; Durable Objects store a GB more cheaply but add request and duration billing.
Indexes change the row calculation. D1 and SQLite-backed Durable Objects count index updates as additional rows written, while missing indexes increase scanned rows. A production estimate should use measured rows_read, rows_written, CPU time, duration, and storage from representative queries.
Where each option wins
Choose D1 per tenant when tenants are small, relational CRUD is enough, strong storage separation is valuable, cross-tenant queries are rare, and the team is prepared to build provisioning, fleet migrations, and a binding strategy.
Choose a Durable Object per tenant when the tenant is genuinely a coordination boundary: ordered mutations, live connections, schedules, counters, bookings, or stateful workflows. Do not choose it merely because it contains SQLite.
Choose PostgreSQL schemas when implementation speed, rich SQL, extensions, cross-tenant administration, mature backup tooling, and provider portability outweigh the shared-cluster blast radius. Pay particular attention to roles, search_path, connection reuse, and migration scale.
Choose MySQL databases when the team and product already fit MySQL, database-level grants are a natural boundary, and the operational model of one shared server is acceptable. Remember that “database per tenant” here does not mean “server per tenant”.
A hybrid may be the strongest design: a conventional relational control plane, Workers for stateless APIs, D1 for small isolated tenant datasets, and Durable Objects only for entities that need coordination. Components become cheaper when each has a specific job instead of a fashionable postcode.
The proof worth building
This article ends with a model, not a verdict. The next step is to implement the same small application on D1, Durable Objects, PostgreSQL schemas, and MySQL databases, then make each version do the same work. I plan to run that experiment soon and publish the measurements rather than quietly replacing the assumptions.
| Stage | What it should reveal | Work to run |
|---|---|---|
| 1 tenant | Correctness and the honest cost of one ordinary customer | Provision, seed, browse, write, run background jobs, migrate, export, delete, and restore |
| 100 tenants | Whether routing, connection reuse, migrations, and observability remain pleasant | Steady mixed traffic, parallel background work, rolling migration, and one deliberately failed migration |
| 1,000 tenants | Fleet behaviour rather than single-database benchmark speed | Warm traffic, cold tenants, burst traffic, a noisy neighbour, queue retries, bulk migration, and cross-tenant reporting |
The dataset and business actions must remain identical. A dashboard view should execute the same logical reads everywhere; a write should change the same records; a QA job should make the same number of external calls and persist the same result. The external API will need a controlled test double, otherwise its latency and rate limits will measure somebody else’s system.
The traffic generator should mix four workloads instead of chasing one impressive requests-per-second number:
- Interactive reads: login, bootstrap data, dashboard refresh, list and detail views.
- Interactive writes: create, update, comment, react, and read the result back immediately.
- Background QA work: scheduled and queued jobs that call APIs, scan data, write results, retry failures, and run even when no browser is open.
- A noisy tenant: one tenant producing a short burst while the others continue at their normal rate.
For every stage I want the same ledger: successful and failed operations; p50, p95, and p99 latency; Worker CPU and wall time; SQL statements; rows scanned and written; Durable Object calls and duration; queue operations and retries; storage growth; migration time; restore time; observed RPO and RTO; and the actual bill. We should also record how much custom provisioning and recovery code each implementation required. Developer time is part of the architecture even when the cloud invoice politely omits it.
The first run stops at 1,000 tenants deliberately. That is large enough to expose fleet problems while remaining small enough to inspect failures rather than merely admire a graph. If the systems behave sensibly, scaling the generator further becomes a second experiment. The resulting cross-tenant event set can also become the input for a separate Snowflake and data-lake test; analytical storage is a different question from serving the application itself.
Working conclusion: the five-dollar headline is a useful hypothesis for mostly sleeping tenants. It is not evidence. Evidence starts when the same application, data, background work, failures, and recovery exercise have run against every option.
Sources and pricing date
Platform limits and prices were checked on 26 October 2026. Cloud platforms do enjoy moving the furniture, so check the current documentation before treating any number as a quotation.
- Cloudflare Workers pricing
- Cloudflare Workers invocation and subrequest limits
- Workers Static Assets billing
- Cloudflare Queues pricing
- Cloudflare Workflows pricing
- Cloudflare D1 pricing
- D1 query and row metrics
- Cloudflare D1 limits
- D1 Time Travel and backups
- D1 global read replication
- Cloudflare Durable Objects pricing
- SQLite-backed Durable Object storage and PITR
- Durable Object data location
- Connecting Workers to databases
- How Hyperdrive works
- Workers for Platforms pricing
- PostgreSQL schemas and privileges
- PostgreSQL pg_dump
- PostgreSQL continuous archiving and PITR
- MySQL database and schema terminology
- MySQL Shell dump and load utilities
- MySQL point-in-time recovery
- Supabase pricing reference