Table of Contents
The short answer: the limits that actually bite
Five Cloudflare constraints cause most production surprises: the 1,000 KV namespaces per account ceiling, D1 read-replica staleness on a write-then-immediately-read path, Worker CPU time, the subrequest cap per invocation, and KV taking up to 60 seconds or more to propagate a write globally. Everything else on the limits pages you will either never approach, or will hit loudly enough to diagnose in minutes.
What makes those five dangerous is not that they are the tightest numbers. It is that all five fail quietly. Most fail in a way that looks like a bug in your own code, and the KV ceiling fails in a way that looks like an infrastructure outage with no error attached to it at all.
Every figure below was checked against Cloudflare's published limits documentation on 26 July 2026. Cloudflare raises limits regularly, sometimes by an order of magnitude, so confirm anything you design around at developers.cloudflare.com rather than trusting a blog post, including this one.
Note
This post is written from operating a platform, not from a benchmark. Where we cite a behaviour, the file path in the Fabricate codebase that handles it is named so the claim is checkable.
The KV namespace ceiling is the hardest wall
Cloudflare allows 1,000 Workers KV namespaces per account, and that figure is identical on Free and Paid. It is one of the few Cloudflare limits that does not move when you start paying, which makes it the most important number for anyone building a multi-tenant platform where each tenant gets its own namespace.
The arithmetic is unforgiving. If a generated app provisions two namespaces and you never reclaim them, the platform stops provisioning anything new after a few hundred apps. Not "gets slower". Stops. Every preview, every deploy, every path needing a KV binding fails from that moment until slots are freed.
In Fabricate this is enforced explicitly rather than left to Cloudflare to reject. `worker/services/sandbox/resourceProvisioner.ts` defines `KV_NAMESPACE_HARD_LIMIT = 1000` and `KV_NAMESPACE_WARN_THRESHOLD = 900`, lists account namespaces before every create, and refuses at the hard limit with a Sentry capture tagged `kv_namespace_limit_reached` at fatal level. The comment above that branch calls it a platform-down signal rather than a per-app error, because that is what it is.
The warn threshold matters more than the hard limit. At 1,000 you are already down. At 900 you still have roughly a hundred provisions of headroom, consistently enough time to run a reclaim pass before any user notices.
Reclaim lives in `worker/services/kv-cleanup/kv-orphan-cleanup-job.ts`, a daily cron with four passes: registry entries whose app no longer exists, namespaces matching the generated naming pattern that no registry references, duplicates for the same app prefix, and namespaces belonging to apps generated once and abandoned without ever publishing. That fourth pass is the largest cohort and the one the first three structurally cannot catch, because those apps still exist and their namespaces are still legitimately referenced. They are simply never coming back.
Its age thresholds are deliberately asymmetric: 14 days for a completed app never deployed, 30 days for one still marked as generating. A completed app is not doing anything, so early reclaim is safe. An app stuck in generating might be a crashed first generation, and the wider margin protects a user returning to an old draft.
Important
KV namespace titles are not unique. A create request that times out but actually landed server-side will, on a naive retry, produce a second namespace with the same title and a different id: a silent leak against a 1,000-slot budget. Our provisioner passes an onBeforeRetry hook that re-lists and reuses the already-created namespace instead of re-POSTing. D1 database names are unique, so a retry there returns a 409 you can resolve by lookup.
Quota exhaustion does not look like a quota error
The most useful thing we have learned operating on Cloudflare is that quota exhaustion almost never surfaces as a quota error. It surfaces as an unrelated-looking symptom several layers away, and debugging the symptom costs hours in the wrong file.
Here is the actual chain from the KV ceiling. The namespace create fails. The provisioning step records a resource that is not ready. The generated wrangler configuration ends up without a resolved binding. The deploy produces a container whose Worker cannot start, because a binding it references does not exist. The container never binds its port, the health check never passes, and the user sees a preview pane that spins and times out.
Nothing in that sequence says "quota". The user reports that the preview never came up. Your error tracker, unless you instrumented the provisioner specifically, shows a deployment timeout. Every instinct pushes you toward the container runtime, the one layer working perfectly. The habit that fixes this is cheap: when a previously-working provisioning path starts failing, check account-level quota state before reading a stack trace. Two questions decide it in under a minute.
Tip
Instrument the count, not just the failure. Emitting "KV namespaces in use: 903 of 1000" as a warning every time you provision turns a future multi-hour outage into a ticket you handle on a Tuesday afternoon. The failure event alone tells you nothing you can act on before it is too late.
Is it broad?
Is the failure hitting unrelated users, apps and code paths at once? Application bugs correlate with something: a feature flag, a template, a route, a payload shape. Quota exhaustion correlates with nothing except time. If the only thing failing requests share is that they need to create a resource, you are looking at a quota.
Is it simultaneous?
Did it start for everyone at the same minute, without a deploy? Code regressions arrive with a release. Quota walls arrive when a counter crosses a threshold, which can happen at three in the morning with nobody touching the system. A sharp, deploy-less onset across the fleet is close to diagnostic on its own.
A path healthy for months that then fails uniformly is far likelier to have run out of something than to have suddenly become wrong. Unchanged code that breaks at scale is usually a resource problem.
Ready to Build?
Start building your full-stack application with Fabricate. Free tier available — no credit card required.
Start Building FreeD1 limits: the numbers and the ones that bite
D1's published limits are generous at the top end and tight in a few specific places that catch generated and hand-written code alike. As of 26 July 2026, Cloudflare documents 50,000 databases per account on Workers Paid and 10 on Free, a maximum database size of 10 GB paid and 500 MB free, and total account storage of 1 TB paid and 5 GB free.
The database count is not the problem for most people. The two that actually cause incidents are the bound-parameter cap and the per-invocation query cap.
A single D1 query accepts at most 100 bound parameters. That sounds like plenty until you write a bulk insert: thirty rows with four columns each is 120 parameters, and the failure appears only once real data volume arrives, so it passes every test written with three fixture rows. The fix is chunking, and the right time to write the chunking is the first time you write the insert.
The per-invocation query cap is 1,000 on Workers Paid and 50 on Free. Fifty is easy to exceed by accident: any handler looping over a result set and issuing one query per row passes locally with a small table and fails in production with a real one. That is the classic N+1 problem with a hard ceiling attached.
Beyond those, a table holds at most 100 columns, rows and strings and BLOBs are capped at 2,000,000 bytes, a single SQL statement at 100,000 bytes, and query duration at 30 seconds.
| Limit | Free plan | Paid plan | How it usually shows up |
|---|---|---|---|
| KV namespaces per accountOur Pick | 1,000 | 1,000 | All new provisioning fails at once; users see previews that never load |
| D1 databases per account | 10 | 50,000 | Create returns an error; usually loud and easy to diagnose |
| D1 database size | 500 MB | 10 GB | Writes begin failing while reads keep working |
| D1 bound parameters per queryOur Pick | 100 | 100 | Bulk inserts fail only once row counts grow past fixtures |
| D1 queries per Worker invocation | 50 | 1,000 | An N+1 handler that works on small tables and dies on real ones |
| Worker CPU time per invocation | 10 ms | 30 s default, up to 5 min | Intermittent failures under load on CPU-heavy paths only |
| Subrequests per invocation | 50 | 10,000 | Fan-out jobs truncate silently once the population grows |
| Simultaneous open connectionsOur Pick | 6 | 6 | Parallel fetches serialise; latency scales with count, not concurrency |
| KV value size | 25 MiB | 25 MiB | Write rejected once cached payloads outgrow expectations |
| Worker script size (gzipped) | 3 MB | 10 MB | Deploy rejected after a dependency is added |
Cloudflare published limits as documented on 26 July 2026. Verify at developers.cloudflare.com before designing around any of these.
D1 read replicas and the write you cannot read back
D1 read replication is opt-in, and once you enable it, a read issued immediately after a write can be served by a replica that does not yet contain that write. Cloudflare's documentation is direct about this: at any given time a read replica may be arbitrarily out of date. Replication buys global read latency and charges read-your-writes consistency unless you ask for it back.
The mechanism for asking is the Sessions API. `withSession('first-primary')` routes the first query in a session to the primary, guaranteeing you observe your own writes. `withSession('first-unconstrained')` routes to whichever replica is closest. Fabricate wraps both in one helper in `worker/database/database.ts`, where `getReadDb('fresh')` maps to first-primary and `getReadDb('fast')` maps to first-unconstrained.
The rule we arrived at after being bitten: any read that gates a decision must be primary-consistent, and reads that only render something can be fast. Cap checks, entitlement checks, uniqueness checks and idempotency checks are decision reads. A dashboard, feed or listing page is a render read.
The concrete case in our codebase is a cap check. `AppService.getUserBackendEnabledAppCount()` counts how many of a user's apps have a backend provisioned, and the free plan caps that at three via `FREE_TIER_BACKEND_APP_CAP` in `worker/agents/execution/backendProvisioning.ts`. It takes a fresh read, and the comment says why: served from a replica, the lag between a `markAppBackendEnabled` write and the next count would let a rapid successive provision undercount and slip past the cap. That one is a guard rather than a post-mortem, and the difference is worth keeping straight.
The post-mortem is on the function beside it. `isAppBackendEnabled()` exists because the cap check previously consulted an in-memory flag that is set when the user approves a backend, before the D1 row is written, and a cap of three let a fourth app through on the first approval. Different mechanism, same shape: a decision taken against state that has not landed yet. Replica lag is that mistake in a form you cannot see anywhere in your own code, which is why the rule is about the kind of read rather than about any one bug.
A gentler version hits listings. Newly published apps lag on replicas and can be missing from a Discover feed. `AppService` uses a fresh read for page one and replica reads after: correctness where a user notices absence, latency everywhere else.
// worker/database/database.ts -- one helper, two consistency guarantees.
// 'fresh' => withSession('first-primary') : reads your own writes
// 'fast' => withSession('first-unconstrained'): lowest global latency
// Decision read. A stale count here lets a user exceed a hard cap,
// because the write that should have incremented it has not replicated.
const count = await this.getReadDb('fresh')
.select({ count: sql`COUNT(*)` })
.from(apps)
.where(and(eq(apps.userId, userId), eq(apps.backendEnabled, true)));
if (count >= FREE_TIER_BACKEND_APP_CAP) return denyProvision();
// Render read. Being a second behind costs nothing a user will notice.
const feed = await this.getReadDb('fast')
.select()
.from(apps)
.where(eq(apps.visibility, 'public'))
.limit(20);KV is eventually consistent, including its negatives
Workers KV changes may take up to 60 seconds or more to become visible in other global network locations, because those locations serve cached versions of the value until they time out. This is documented behaviour, not a degradation, and it is the correct trade for what KV is designed to be: an extremely fast read-mostly store.
The part that catches people is that negative lookups are cached too. If a location asks for a key that does not exist yet, the absence is cached, and the same delay applies to noticing the value was subsequently created. A "does this exist?" check on a freshly written key can keep returning no for a full cache lifetime after the write landed. The default `cacheTtl` is 60 seconds and Cloudflare recommends raising it for performance, which lengthens the staleness window. Writes to the same key are limited to one per second.
Cloudflare's guidance is that while changes are usually immediately visible in the location where they were made, this is not guaranteed and should not be relied on. Treat that as absolute: no code path should write a KV key and read it back as part of the same logical operation. If a value must be readable immediately after it is written, it belongs in D1 or a Durable Object.
Fabricate's generation guidance carries part of this and is worth quoting honestly rather than claiming more than it does. The KV tool description in `worker/agents/tools/toolkit/kv-storage.ts` tells the model that KV is optimised for read-heavy workloads and eventually consistent. The blueprint planning prompt in `worker/agents/planning/blueprint.ts` labels it the same way and routes counters and other real-time state to Durable Objects rather than KV. But that same prompt still lists sessions under KV, which is reasonable for a session read on a later request and wrong for anything that must be readable the instant it is written. Guidance that names a trade-off is not the same as guidance that prevents the mistake.
Important
The most expensive version of this bug is an idempotency guard built on KV: write a key to mark work as done, check the key before doing the work again. Under retry pressure the check runs before the write is visible and the work runs twice. Idempotency guards need strong consistency. D1 with a unique constraint, or a Durable Object, will do it correctly.
Ready to Build?
Start building your full-stack application with Fabricate. Free tier available — no credit card required.
Start Building FreeWorker CPU time, subrequests and the six-connection cap
Worker CPU time is 10 ms per invocation on Free and 30 seconds by default on Paid, configurable up to 5 minutes. The critical detail is that CPU time is not wall-clock time: awaiting a fetch, a D1 query or a KV read does not consume it. HTTP requests have no wall-clock duration limit, while Cron Triggers, Durable Object alarms and Queue consumers are capped at 15 minutes.
So a Worker spending 20 seconds waiting on slow upstreams is fine, while one spending 40 ms parsing a large JSON payload on Free is not. A CPU limit bites intermittently and only on the heaviest requests, which reads as flakiness. Memory is 128 MB per isolate on both plans, and script size after gzip is 3 MB on Free and 10 MB on Paid.
Subrequests are capped at 50 per invocation on Free and 10,000 on Paid. Alongside sits a quieter cap that surprises nearly everyone: a Worker may hold only six connections open simultaneously while waiting for response headers, after which a connection stops counting. Firing 50 fetches in a Promise.all does not give 50-wide concurrency. It gives six-wide with a queue, and latency scales with the count rather than staying flat.
Cron jobs share a subrequest budget
The least documented gotcha here is that the subrequest budget belongs to the invocation, not to the job. If two scheduled tasks run on the same cron trigger, they share one budget, and the second to run is the one that fails.
Fabricate's custom-domain drift checker caps itself at 300 domains per run for exactly this reason. The comment in `worker/services/custom-domains/domain-drift-job.ts` notes that each domain costs up to three fetch subrequests, and that the job shares its 03:00 UTC invocation, and therefore its budget, with billing reconciliation. Hitting the ceiling logs at error level rather than truncating silently, because past that scale the job needs its own cron tick or sharding.
Our own comment is already out of date
That same comment states the budget as 1,000 per invocation. Cloudflare's current documentation says 10,000 on Paid. The comment is stale, our ceiling is conservative by a factor of ten, and nothing broke because the drift ran in the safe direction. Worth naming rather than quietly fixing: any constant mirroring a platform limit should carry the date it was verified, otherwise you cannot tell a deliberate safety margin from a number nobody has checked in two years.
Request bodies, response sizes and R2
Request body size is governed by your Cloudflare account plan rather than your Workers plan: 100 MB on Free and Pro, 200 MB on Business, 500 MB by default on Enterprise. Cloudflare does not enforce a response body size limit, though CDN cache limits apply at 512 MB for Free, Pro and Business.
KV keys are capped at 512 bytes, values at 25 MiB, metadata at 1,024 bytes. R2 is far more generous: objects up to 5 TiB, a single-part upload ceiling of 5 GiB, and up to 10,000 multipart parts.
The design consequence is the one every serverless platform arrives at. Do not proxy large uploads through a Worker: issue a presigned URL and let the client write to R2 directly. Streaming a 90 MB file through an isolate with 128 MB of memory works right up until two users do it at once.
The R2 limit that catches platform operators rather than app developers sits on the management plane. Bucket management operations are limited to 50 per second, concurrent writes to the same object key to one per second, and the Cloudflare REST API to 1,200 requests per five minutes across all R2 REST operations. A migration script that enumerates and mutates thousands of buckets meets that last one as HTTP 429s partway through, leaving the migration half-applied. Rate-limit your own tooling before Cloudflare does it for you.
What a pre-deploy gate can and cannot catch
Some of these failures are structural and catchable before anything deploys. Most are not, and being honest about which is which keeps a validation layer from becoming theatre. Fabricate runs an AST-based pre-deploy safety gate at `worker/agents/validators/preDeploySafetyGate.ts` before generated code reaches a preview, and several of its named rules exist because of the failure modes in this post.
`unresolved_resource_placeholder` fires when a generated `wrangler.jsonc` still contains raw `{{KV_..._ID}}` or `{{D1_..._ID}}` placeholders. It matters because the environments disagree: a production deploy rejects the config outright, while sandbox dev tolerates it and the binding never resolves. The app boots and dies on its first storage call, which is far more confusing than a rejected deploy.
`unresolved_d1_binding` fires when Worker code references `env.DB` but the wrangler config does not exist or has no `d1_databases` section. `empty_drizzle_config` fires when `schema.ts` declares tables but `drizzle.config.ts` is missing or empty, so `drizzle-kit generate` produces no migration SQL and the tables are never created. The validator comment explains why that one is nasty: template-seeded tables keep working, so the app looks partially alive, which is worse than being cleanly broken.
Now the honest part. A static gate cannot see how many KV namespaces your account has consumed, know whether a replica has caught up, predict CPU under a payload it has never seen, or count subrequests in a loop whose bounds come from a database. Those are runtime and account-state properties, and no AST analysis surfaces them.
So the split is: gate what is structural, instrument what is not. A missing binding is structural, so catch it before deploy. A quota at 903 of 1,000 is state, so emit it and alarm on it. Confusing the two gives you either a gate that blocks nothing useful or alerts full of things a compiler should have caught.
Ready to Build?
Start building your full-stack application with Fabricate. Free tier available — no credit card required.
Start Building FreeThe operator checklist
If you are running anything multi-tenant on Cloudflare, these seven habits will save you the incidents described above.
1. Emit quota counts as metrics, not just quota failures. Alarm at roughly 90 percent of any account-level ceiling. The failure event arrives too late to act on.
2. Give every per-tenant resource a reaper. Anything created per user, per app or per deploy needs a scheduled job that reclaims it, with age thresholds tuned to how long an abandoned tenant might plausibly return.
3. Route decision reads to the primary. Caps, entitlements, uniqueness checks and idempotency guards must not be served from a replica. Render reads can be fast.
4. Never write a KV key and read it back in the same logical operation. If you need read-after-write, use D1 or a Durable Object.
5. Budget subrequests per cron invocation, not per job. If two jobs share a trigger they share the ceiling, and the second one loses.
6. Stream large uploads directly to R2 with a presigned URL rather than through a Worker isolate.
7. Date every constant that mirrors a platform limit. Cloudflare raises limits, and an undated constant is indistinguishable from a forgotten one.
None of this is exotic. Quota exhaustion hurts on Cloudflare specifically because the platform is reliable enough that when something breaks broadly, your first instinct is to blame your own code. Often that instinct is right. When the breakage is broad, simultaneous and arrived without a deploy, spend sixty seconds proving it before you spend three hours debugging it.
Ready to Build?
Start building your full-stack application with Fabricate. Free tier available — no credit card required.
Start Building Free