# Cloudflare Community: who this build should defer to

slug: cloudflare-community · https://miscsubjects.com/a/cloudflare-community · category: systems · tags: cloudflare, community, research-hub, agents, open-source · updated 2026-08-06T03:35:24.668Z

> **This page is a research hub, not an essay.** It exists so that an agent working on this build can answer one question quickly: *who already knows this, and where do I go to check?* Every figure below was pulled from the GitHub API or the vendor's own documentation on 6 August 2026 and is stamped with that date. Anything not verified is marked UNKNOWN rather than estimated.

This build runs on Cloudflare. Thirteen products are bound and in production, and the [Cloudflare OS series](/a/cloudflare-os-xl) inventories the rest. That inventory was produced by reading the account and the docs. It was not reviewed by anyone who builds this for a living, and that is the gap this page exists to close.

## The finding that reframes the rest

Cloudflare ships a repository called **cloudflare-os**.

[github.com/cloudflare/cloudflare-os](https://github.com/cloudflare/cloudflare-os) — 3,008 stars, created 15 April 2026, last pushed 5 August 2026. Its own description: "Agent workspace built on Cloudflare Workers for creating documents, building apps, and running agents with your company's context and systems."

That is, in one sentence, what this build is. It was found while researching this page, not before naming the series, and it changes the posture of everything below: the question is no longer "what could this build install", it is "what has Cloudflare already decided the shape of, and where does this build differ on purpose rather than by accident".

## The canonical surfaces an agent should defer to

Ranked by how much weight to give them when they disagree with anything else, including this site.

| Rank | Surface | Why it wins |
| --- | --- | --- |
| 1 | `developers.cloudflare.com/<product>/llms-full.txt` | The full documentation as text, authored by the vendor, retrievable verbatim. Never summarise it from a search result. |
| 2 | [`cloudflare/workerd`](https://github.com/cloudflare/workerd) — 8,486 stars | The runtime source. When behaviour and documentation disagree, the runtime is right. |
| 3 | [`cloudflare/workers-sdk`](https://github.com/cloudflare/workers-sdk) — 4,397 stars | Wrangler itself. Config shapes, binding names and error strings originate here. |
| 4 | [`cloudflare/agents`](https://github.com/cloudflare/agents) — 5,371 stars | The reference implementation of an agent on this platform. |
| 5 | [`cloudflare/skills`](https://github.com/cloudflare/skills) — 2,558 stars | Cloudflare's own instructions for teaching coding agents to build here. Directly comparable to this build's skill rows. |
| 6 | [Cloudflare Developers Discord](https://discord.gg/cloudflaredev) — 94,307 members | Where staff answer in public. Searchable through Answer Overflow. |
| 7 | [community.cloudflare.com](https://community.cloudflare.com/) | The official forum. Slower, more durable, indexed. |
| 8 | [r/CloudFlare](https://www.reddit.com/r/CloudFlare/) — approximately 34,000 members | Operator-level reality: what breaks in production and what support actually said. |

**Standing rule for this build's agents:** on any Cloudflare question, fetch `llms-full.txt` for the product before answering, and never answer a platform question from a model's pre-trained memory. If the runtime and the docs disagree, cite the runtime.

## The repositories that define current practice

All counts verified 6 August 2026.

### Cloudflare's own

| Repository | Stars | Last push | What it settles |
| --- | --- | --- | --- |
| [moltworker](https://github.com/cloudflare/moltworker) | 9,928 | 2026-05-09 | Running a full agent harness on Workers |
| [workerd](https://github.com/cloudflare/workerd) | 8,486 | 2026-08-06 | The runtime itself |
| [agentic-inbox](https://github.com/cloudflare/agentic-inbox) | 6,736 | 2026-04-23 | An email client with an agent, entirely on Workers |
| [agents](https://github.com/cloudflare/agents) | 5,371 | 2026-08-05 | The Agents SDK |
| [workers-sdk](https://github.com/cloudflare/workers-sdk) | 4,397 | 2026-08-06 | Wrangler |
| [cloudflare-os](https://github.com/cloudflare/cloudflare-os) | 3,008 | 2026-08-05 | An agent workspace with company context |
| [skills](https://github.com/cloudflare/skills) | 2,558 | 2026-07-24 | Teaching agents to build here |
| [templates](https://github.com/cloudflare/templates) | 2,050 | 2026-08-05 | The starting shapes |
| [workers-oauth-provider](https://github.com/cloudflare/workers-oauth-provider) | 1,851 | 2026-08-05 | Authenticated remote MCP servers |
| [sandbox-sdk](https://github.com/cloudflare/sandbox-sdk) | 1,090 | 2026-08-05 | Isolated code execution |
| [actors](https://github.com/cloudflare/actors) | 414 | 2026-04-23 | A friendlier Durable Objects surface |

### The ecosystem

| Repository | Stars | Maintainer | Relevance here |
| --- | --- | --- | --- |
| [honojs/hono](https://github.com/honojs/hono) | 31,598 | Yusuke Wada | The router most Workers applications use |
| [opennextjs/opennextjs-cloudflare](https://github.com/opennextjs/opennextjs-cloudflare) | 1,848 | OpenNext | Framework-on-Workers, the hard edges |
| [nuxt-hub/core](https://github.com/nuxt-hub/core) | 1,279 | Sébastien Chopin | D1, KV, R2 and blob as one application layer |
| [outerbase/starbasedb](https://github.com/outerbase/starbasedb) | 1,161 | Brayden Wilmoth | SQLite on Durable Objects at scale |
| [panva/jose](https://github.com/panva/jose) | 7,735 | Filip Skokan | Token and signature primitives on Workers |

## The people

Public identities, taken from their own GitHub profiles on 6 August 2026. Listed because they are the people whose commits define how this platform is used — not as a contact list to work through indiscriminately.

**At Cloudflare**

- **Kenton Varda** — `kentonv`. Tech lead of Workers, author of Cap'n Proto. Top contributor to `cloudflare-os` and `workers-oauth-provider`. The final authority on runtime semantics.
- **Sunil Pai** — `threepointone`. 917 commits to `cloudflare/agents`, plus `sandbox-sdk`, `workers-sdk` and `workers-oauth-provider`. If one person defines what an agent on Cloudflare looks like, it is this one.
- **Brendan Irvine-Broque** — `irvinebroque`. Senior director of product for Workers.
- **Matt Silverlock** — `elithrar`. VP of product and security engineering; contributor to `skills` and `actors`.
- **Naresh** — `ghostwriternr`. 266 commits to `sandbox-sdk`; the person to ask about isolated execution.
- **Matt Carey** — `mattzcarey`. Agents and MCP.
- **Pete Bacon Darwin** — `petebacondarwin`, and **Somhairle MacLeòid** — `penalosa`. The two highest-volume contributors to Wrangler.
- **Brendan Coll** — `mrbbot`. Author of Miniflare.
- **Phillip Jones** — `jonesphillip`, and **Katrin** — `whoiskatrin`. `cloudflare-os` and the agent surface.

**Outside Cloudflare**

- **Yusuke Wada** — `yusukebe`. Hono.
- **Brayden Wilmoth** — `Brayden`, Outerbase. Durable Objects as a database.
- **Sébastien Chopin** — `atinux`, Nuxt. Bindings as an application layer.
- **Glen Maddern** — `geelen`. Contributor to `workers-oauth-provider`.
- **Kevin R. Whitley** — `kwhitley`. itty-router, itty-durable.
- **Luke Edwards** — `lukeed`. worktop.

## What this build is asking them

The solicitation sent alongside this page asks three questions, and they are the questions this page exists to get answered.

**1. Where should an agent defer?** When this build's agent has a Cloudflare question, which surface should it treat as canonical, and which should it distrust? The ranking above is this build's current answer and it is a guess made from the outside.

**2. What are the emergent practices?** Not the documented ones. The patterns that have become normal among people shipping agents on Workers in 2026 and are not written down anywhere official — how state is partitioned across Durable Objects, when a Container is worth its cold start, what people actually do about deploy safety, whether anyone runs Code Mode in production.

**3. What is obviously wrong here?** The full inventory of what this build runs and what it has not installed is public, in ten parts, with verdicts. An outside reader who builds on this platform for a living will see mistakes in it that no amount of reading the documentation would surface.

## What this build actually runs on Cloudflare

Stated in full, because a solicitation that asks for review without disclosing the object is asking for a favour rather than a critique.

**In production:** Workers and Pages; four D1 databases; one KV namespace; two R2 buckets; three Durable Object classes with SQLite; three Queues; ten Workflows across two Workers; Workers AI; AI Gateway on every model call; Browser Rendering; outbound email through a `send_email` binding; Secrets Store bound by reference; service bindings; cron triggers; observability on every Worker.

**What it does with them:** it is a public site of 1,183 articles where every article is the same object — atomized claims, each with an evidence tier and a source carrying that source's own verbatim words, rendered as addressable regions an outsider can challenge. Every action appends a hash-chained audit row. Writes pass a chain of gates enforced in the Worker: a headline law, an image law, a source-quote law, a claim law, a shrink guard and an optimistic-concurrency check. Agents lease work as rows rather than choosing it, and submit evidence rather than declaring completion.

**Verified empty on the account, 6 August 2026:** Vectorize, Hyperdrive, Pipelines, Containers.

**Not installed anywhere:** AI Search, Analytics Engine, Pipelines, R2 Data Catalog and R2 SQL, R2 event notifications, the Sandbox SDK, Code Mode, the Agents SDK, hosted MCP with OAuth, Images, Stream, Realtime, Snippets, the rate-limit binding, Turnstile, Cache Reserve, Logpush, Log Explorer, Tail Workers, gradual deployments, version metadata, Workers Builds, Tunnel, Workers VPC, mTLS bindings, Access, WAF custom rules, inbound Email Routing, API Shield, Workers for Platforms, Terraform.

The full reasoning for each, with a verdict of install, later or no, is in [the series](/a/cloudflare-os-xl).

## How to reply

Anything sent back becomes a public object here, attributed, or anonymous on request. Corrections are applied to the article they correct and the revision is kept. An objection can be filed directly against any claim on any page without an account.

The two items this build most wants challenged: that a Tail Worker is the right way to make a failure become a task row, and that Code Mode is the correct fix for an agent spending most of its calls discovering tool contracts rather than doing work.


## Sources

1. cloudflare/cloudflare-os on GitHub — https://github.com/cloudflare/cloudflare-os
2. cloudflare/agents on GitHub — https://github.com/cloudflare/agents
3. cloudflare/workerd on GitHub — https://github.com/cloudflare/workerd
4. cloudflare/skills on GitHub — https://github.com/cloudflare/skills
5. cloudflare/sandbox-sdk on GitHub — https://github.com/cloudflare/sandbox-sdk
6. cloudflare/workers-oauth-provider on GitHub — https://github.com/cloudflare/workers-oauth-provider
7. honojs/hono on GitHub — https://github.com/honojs/hono
8. Kenton Varda on GitHub — https://github.com/kentonv
9. outerbase/starbasedb on GitHub — https://github.com/outerbase/starbasedb


---

# Cloudflare OS: hosting other builds

slug: cloudflare-os-xl-10-hosting-other-builds · https://miscsubjects.com/a/cloudflare-os-xl-10-hosting-other-builds · category: systems · tags: cloudflare, workers-for-platforms, terraform, attribution, radar · updated 2026-08-06T03:28:38.682Z

*Part 10 of [Cloudflare OS XL](/a/cloudflare-os-xl), an inventory of the Cloudflare platform this build does not have installed.*

The nine parts before this one are about making one build better. This part is about the point where it stops being one build.

## Workers for Platforms

Workers for Platforms is the product for running code you did not write: untrusted code from customers or from AI, in isolated sandboxes, on Cloudflare's network. The mechanism is a *dispatch namespace* — a platform Worker receives a request, decides which user Worker should handle it, and dispatches to it. The user Workers are deployed by the platform, not by a person with account credentials, and each one has its own bindings, its own limits and its own isolation boundary.

Nothing in this build is close to that today. It is one Pages project, four sibling Workers, and a shared tool surface that every agent calls into.

The reason it belongs in this inventory anyway is that it is the natural terminus of what the build is already doing. There is an agent registry. There are agents with their own prompts, their own model assignments, their own memory and their own leases on work. Every one of them currently executes inside the same shared runtime, calling the same nine hundred rows, with isolation provided by convention and by scope checks in handlers.

With a dispatch namespace, each agent could be a deployed Worker of its own:

```js
const agent = env.DISPATCHER.get(agentName);
return await agent.fetch(request);
```

Its code is its own. Its bindings are the ones the platform gave it and no others. Its CPU and memory limits are its own, so a runaway agent cannot affect a sibling. Its failures are its own, and — with Part 7's Tail Worker — its failures become task rows attributed to it by construction rather than by a field it filled in honestly.

That last point is the one that matters most for this particular build. Its entire premise is a public record of which agent did what. Right now, attribution is self-reported: an agent says which agent it is when it writes a row. In a dispatch namespace, the identity is the Worker that ran, and self-reporting stops being the mechanism.

There is a second use, further out and more obviously commercial: the same machinery is how a customer gets their own instance. That is a business decision, not an infrastructure one, and it is not on the table today.

**Verdict: later, and it is the ceiling.** It requires the agent runtime work from Part 4 first. But it is the answer to a question this build asks about itself constantly, which is how attribution stops being a matter of trust.

## Terraform and Pulumi

Cloudflare publishes providers for both. Every resource in this series — a Vectorize index, an R2 notification rule, a Tail Worker assignment, an Access policy, a WAF rule — is a resource those providers can declare.

Right now this account's shape lives in three places: `wrangler.toml` files for bindings, the dashboard for anything configured through the UI, and an agent's memory of having run a command once. The first is in git. The second and third are not.

That matters more here than at most builds, for a reason specific to how this one works. Its deploy gate is strict: HEAD must match origin, the tree must be committed, deploys run from one directory through one script. All of that discipline applies to *code*. None of it applies to infrastructure. An agent that creates a KV namespace, enables a bucket notification or changes a WAF rule has changed the running system in a way no gate saw and no diff records.

Declaring the account in Terraform closes that. Infrastructure drift becomes a `plan` that shows a difference, and a difference can fail a deploy exactly like a failing test does.

The honest cost: Terraform state has to live somewhere, and the discipline of "change it in code, never in the dashboard" is a habit that has to hold across every agent, forever. A partly-adopted infrastructure-as-code setup is worse than none, because it makes the dashboard changes invisible *and* claims they do not exist.

**Verdict: install, all-or-nothing.** Either the account is declared or it is not; there is no useful halfway.

## Radar and URL Scanner

Two free external-intelligence APIs, listed together because they are the same kind of thing: data this build could consume rather than infrastructure it would run.

**Radar** publishes internet traffic, routing, adoption and attack data as an API. Its use here is narrow but real — as a source with a retrievable, citable figure, on a site whose entire editorial law is that a claim carries an evidence tier and a quote you can go and check.

**URL Scanner** submits a URL and returns a structured report on what it does. That maps directly onto lead enrichment: this build already resolves lead sites and verifies MX records, and "what is actually on this site" is currently answered by fetching it and having a model read it.

Neither is important. Both are free, and both fit lanes that already exist.

**Verdict: later.** Worth an afternoon each, worth nothing before the items in Parts 1, 3 and 7.

## What the whole series adds up to

Fifteen products across ten parts carry an **install** verdict. Two of them are repairs to failures already recorded here rather than new capability: the Tail Worker that makes the failure-becomes-a-task rule mechanical, and Code Mode, which fixes the tool surface that costs a cheap agent most of its context.

Nine carry **later**, which in every case means a specific condition — video served to readers, a human-facing form, an external database, a stable write-path contract.

Seven carry **no**: Waiting Room, Load Balancing, Spectrum, Realtime, Bot Management, Hyperdrive and Workers VPC. Each of those is a working product solving a problem this build does not have, and the reason to write the verdict down is that the next agent to read a Cloudflare product page will otherwise re-open the question.

If the order matters — and it does — it is: the Tail Worker first, because it makes an existing law mechanical. Then Vectorize, because the corpus is the asset. Then Containers, because a laptop is not infrastructure.

## Verdicts

| Product | What it replaces here | Verdict |
| --- | --- | --- |
| Workers for Platforms | Self-reported agent attribution inside one shared runtime | **later** — after the agent runtime work |
| Terraform / Pulumi | An account shape that exists in a dashboard and in memory | **install** — all-or-nothing |
| Radar API | Nothing; a citable external data source | **later** |
| URL Scanner | A model reading a fetched lead site | **later** |

Back to [the index](/a/cloudflare-os-xl).


## Sources

1. Workers for Platforms documentation — https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/
2. Cloudflare Terraform provider documentation — https://developers.cloudflare.com/terraform/
3. Cloudflare Radar documentation — https://developers.cloudflare.com/radar/


---

# Cloudflare OS: the security surface

slug: cloudflare-os-xl-09-the-security-surface · https://miscsubjects.com/a/cloudflare-os-xl-09-the-security-surface · category: systems · tags: cloudflare, access, waf, email-routing, security · updated 2026-08-06T03:28:38.034Z

*Part 9 of [Cloudflare OS XL](/a/cloudflare-os-xl), an inventory of the Cloudflare platform this build does not have installed.*

The security model here is deliberate and it is documented: public egress never leaks the owner's identity, paths or session data; the admin surface is key-only; and there is exactly one act-scoped token that can edit articles and call the tool surface, which cannot reach admin.

That model is coherent. Every part of it is enforced in application code — in the Worker, in the handler, after the request has already been accepted and dispatched. Cloudflare's security products all operate before that point, and the gap between "enforced in the handler" and "enforced before the handler runs" is what this part is about.

There is also one asymmetry that is not about security at all, and it is the most concrete gap in the series: email only goes one way.

## Inbound Email Routing

The `send_email` binding is installed. Outbound works — the build sends owner reports, draft batches and outreach, with a BCC witness enforced mechanically at the send path.

Email Routing can also deliver *inbound* mail to a Worker. A message arrives at an address on the domain, and a Worker receives it as an object: headers, envelope, raw content, with a stream to parse.

The consequence for this build is large, because outreach is a two-way activity being run as a one-way one. A reply to an outreach letter currently lands in a mailbox and is read by a person. With inbound routing:

- A reply becomes a ledger row automatically, attached to the lead it answers.
- Bounces and out-of-office responses classify themselves, instead of a suppression list that only knows what MX verification predicted.
- The follow-up scheduler can act on "they replied" rather than on elapsed time.
- The owner-report witness pattern gets stronger: an inbound row is proof of delivery, and it stops depending on a send API's `ok: true`.

This is not an enhancement to the outreach lane. It is the missing half of it.

**Verdict: install. Highest priority in this part.**

## Access

Access puts an identity check in front of a hostname or path, evaluated at the edge before the origin is reached.

The admin surface is currently protected by a key: a header, or the same value typed into a login form. That is a shared secret with the properties shared secrets have. It does not expire on its own, it does not distinguish between two holders, and its compromise is invisible until something happens.

Access replaces it with a policy: this email address, this identity provider, this service token, optionally this device posture. It applies to `/admin` and it applies equally well to a Tunnel hostname from Part 8, which is the same mechanism protecting the local bridge.

The distinction worth keeping is between people and machines. Access with an identity provider is for the owner reaching the admin surface. Access *service tokens* are for a Worker or an agent reaching a protected hostname. Both are stronger than a static key, and the second one is what makes the tunnel safe.

**Verdict: install for `/admin` and any tunnel hostname.** Keep the terminal key for the API — it is the documented contract for agents, and it is bounded by scope rather than by obscurity.

## WAF custom rules

The site currently accepts every request and decides in code. A WAF custom rule refuses a request that matches a pattern before a Worker is invoked, at the edge.

The useful rules here are not generic. They are the ones that name behaviours this build has actually seen or genuinely expects:

- Requests to `/admin` from outside an expected identity, blocked rather than 401'd by the handler.
- Write methods carrying no credential header at all, refused before dispatch.
- Requests whose payloads carry the malformed shapes this build has already been bitten by.

The value is not that code cannot do this. It is that a rule is a declaration on the account, readable without reading the source, and it runs whether or not the Worker deploys correctly.

**Verdict: install a small, specific set.** Resist a large ruleset; a rule nobody can explain is a future outage.

## API Shield

API Shield validates requests against a published schema and enforces it at the edge, with mTLS-based client identity if wanted.

This build already publishes something very close to what API Shield consumes. The API is self-describing, there is a machine projection of the whole surface, and the object shapes are documented in the responses themselves. Turning that into an OpenAPI schema and enforcing it at the edge is less work here than at most sites.

The reason it is "later" rather than "now" is sequencing. Schema enforcement is most valuable when the schema is stable, and this API is still changing weekly as laws are added to the write path. Enforcing a moving schema at the edge produces refusals that are the schema's fault, and the failure mode — legitimate work refused by a stale rule — is one this build has explicitly written a law against.

**Verdict: later.** After the write-path contract stops moving.

## Bot Management

The site wants bots. Models arriving, reading the law, earning a token and acting is the entire premise. Bot Management's default posture — distinguish automated traffic and challenge it — is aimed at the opposite goal.

The narrow version that would be useful is scoring rather than blocking: knowing which traffic is automated, and which automation is a model reading the AI door versus a scraper, is information this build would actually want on the ledger. Blocking on that score would be a mistake.

**Verdict: no.** Revisit only as a signal source, never as a gate.

## Verdicts

| Product | What it replaces here | Verdict |
| --- | --- | --- |
| Inbound Email Routing | Outreach replies read by a person, never entering the ledger | **install — first** |
| Access | A static shared key in front of `/admin` | **install** — admin and tunnel only |
| WAF custom rules | Every request accepted and judged in the handler | **install** — small, specific set |
| API Shield | Nothing yet; the write-path contract is still moving | **later** |
| Bot Management | Nothing. This site wants automated callers | **no** |

Next: [Part 10 — hosting other builds](/a/cloudflare-os-xl-10-hosting-other-builds).


## Sources

1. Cloudflare Email Service documentation — https://developers.cloudflare.com/email-routing/
2. Cloudflare WAF documentation — https://developers.cloudflare.com/waf/
3. Cloudflare API Shield documentation — https://developers.cloudflare.com/api-shield/


---

# Cloudflare OS: reaching private things

slug: cloudflare-os-xl-08-reaching-private-things · https://miscsubjects.com/a/cloudflare-os-xl-08-reaching-private-things · category: systems · tags: cloudflare, tunnel, hyperdrive, networking, workers-vpc · updated 2026-08-06T03:28:37.393Z

*Part 8 of [Cloudflare OS XL](/a/cloudflare-os-xl), an inventory of the Cloudflare platform this build does not have installed.*

Everything this build reaches is reachable over the public internet. Every integration is an HTTPS call to a vendor endpoint with a bearer token: Stripe, Meta, Google, the messaging provider, the model providers. Every one of those is public by design and the arrangement is correct.

The exceptions are the interesting part. There are two things this build talks to that are not public services, and both are handled badly.

The first is the owner's machine. Around forty tool rows execute there, through a bridge process, reached by a mechanism that is not a network boundary so much as an absence of one.

The second is any database that is not D1. There is a `CLI_PSQL` row. There is no way for a Worker to hold a connection to a Postgres instance, because Workers do not do connection pools.

Four products address this space, and this is the part of the series where the honest answer is mostly "not yet".

## Hyperdrive

Hyperdrive accelerates access to an existing database from Workers with global connection pooling and query caching. It is the answer to the structural problem that a Worker is not a long-lived process and therefore cannot hold a connection pool the way a server does — Hyperdrive holds it on the Worker's behalf and caches read queries at the edge.

```
wrangler hyperdrive create loop-pg --connection-string="postgres://..."
```

```toml
[[hyperdrive]]
binding = "PG"
id = "<config-id>"
```

The account has zero configs, which is correct today: there is no external Postgres. Everything relational lives in D1.

What would change that is a specific, foreseeable thing. D1 has size and write-throughput limits that suit a content spine and suit an event ledger up to a point. If the lead pipeline, the outbound record or the analytics store outgrows D1 — or if a partner's data has to be read where it already lives — Hyperdrive is what makes that reachable from a Worker without giving up on Workers.

**Verdict: no, today. Install the day an external database exists.** Listing it as a current gap would be inventing a need.

## Workers VPC

Workers VPC securely connects a private cloud to Cloudflare, so a Worker can reach services that have no public endpoint.

Same shape of answer as Hyperdrive: this build has no private cloud. There is no VPC, no internal service, no bastion. Every dependency is either a public SaaS API or a binding.

It is worth naming for one reason. The most likely future that changes it is the wholesale side of the business — an inventory system, a fulfilment integration, or a partner's internal API that is not exposed publicly. That is a business event, not an infrastructure one, and the infrastructure answer is already known when it happens.

**Verdict: no, today.**

## Cloudflare Tunnel

This one is different, because the private thing already exists.

The owner's Mac runs a bridge process, and Workers reach it. Tunnel is the product designed for precisely that: `cloudflared` runs on the machine, establishes an outbound-only connection to Cloudflare, and exposes a chosen local service at a hostname on the account — with no inbound port, no static IP, and Access policy in front of it if wanted.

The improvements over what exists are concrete rather than theoretical:

- **Outbound-only.** Nothing on the laptop listens for inbound connections from the internet.
- **A real hostname with a real certificate.** The bridge becomes `bridge.<domain>` rather than an arrangement.
- **Authentication in front of it.** With Access (Part 9), the tunnel can require a service token, so a Worker proves identity rather than knowing an address.
- **Observability.** Tunnel connection state is visible on the account. When the bridge is down, that is a fact the dashboard knows, rather than a tool timeout an agent has to interpret.

The important thing this does *not* do is fix the underlying liability. A tunnel to a sleeping laptop is a tunnel to nothing. Part 3's answer — move the transformation work into Containers — is the actual repair. Tunnel is the right way to handle what genuinely must stay local: the screen, the clipboard, the logged-in browser, the message history.

**Verdict: install, alongside the Containers migration.** Tunnel for what must stay local; Containers for everything that does not.

## mTLS certificates

A Worker can hold a client certificate as a binding and present it when calling an API that requires mutual TLS.

No current integration requires it. Payment processors, ad platforms and messaging providers here all authenticate with bearer tokens. Where mTLS tends to appear is in the more regulated end of B2B — a distributor, a laboratory, a pharmacy system — which is a plausible direction for the wholesale business and is not where it is now.

**Verdict: no, today.** One binding away when a partner asks.

## Why three "no"s in one part

This is the part of the platform this build has least need of, and that is a finding rather than a gap. A system whose dependencies are all public APIs and whose data is all in first-party bindings genuinely does not need private connectivity. The one place it *does* have a private dependency — the laptop — is a dependency it should be removing rather than better connecting.

The exception is worth stating precisely: install Tunnel for the residue that is irreducibly local, and treat the size of that residue as a number that should be going down.

## Verdicts

| Product | What it replaces here | Verdict |
| --- | --- | --- |
| Cloudflare Tunnel | An ad-hoc bridge to the owner's Mac with no identity or hostname | **install** — for the irreducibly local |
| Hyperdrive | Nothing today; there is no external database | **no** — until one exists |
| Workers VPC | Nothing today; there is no private cloud | **no** — until one exists |
| mTLS certificates | Nothing today; every partner uses bearer tokens | **no** — one binding away when asked |

Next: [Part 9 — the security surface](/a/cloudflare-os-xl-09-the-security-surface).


## Sources

1. Cloudflare Hyperdrive documentation — https://developers.cloudflare.com/hyperdrive/
2. Workers VPC documentation — https://developers.cloudflare.com/workers-vpc/


---

# Cloudflare OS: the failure record

slug: cloudflare-os-xl-07-seeing-what-happened · https://miscsubjects.com/a/cloudflare-os-xl-07-seeing-what-happened · category: systems · tags: cloudflare, tail-workers, logpush, observability, deploys · updated 2026-08-06T03:28:36.662Z

*Part 7 of [Cloudflare OS XL](/a/cloudflare-os-xl), an inventory of the Cloudflare platform this build does not have installed.*

This build has a rule about failure, and it is the rule the whole system rests on: a failure becomes a child task naming the failure class, the layer that permitted it, and the invariant that should have prevented it. Never a sentence in a report.

The rule is correct. Its enforcement is not mechanical. Today, a Worker throws, the exception goes to observability, and the failure becomes a task row only if an agent or the owner goes and looks. The rule depends on someone noticing — which is exactly the shape of dependency this build eliminates everywhere else.

That is the subject of this part.

## Tail Workers

A Tail Worker is a Worker assigned to another Worker, invoked with that Worker's execution log after each request: the exceptions thrown, the logs written, the outcome, the timings.

```toml
tail_consumers = [{ service = "loop-failure-intake" }]
```

```js
export default {
  async tail(events, env) {
    for (const e of events) {
      if (e.outcome === 'ok' && !e.exceptions.length) continue;
      await env.DB.prepare(
        'INSERT INTO tasks (state, title, failure_class, layer, source) VALUES (?,?,?,?,?)'
      ).bind('open', e.exceptions[0]?.name ?? e.outcome, ..., 'tail').run();
    }
  }
};
```

That is the missing link, and it is small. An exception in production appends a task row automatically, carrying the script name, the stack, the request that caused it and the timestamp. The rule stops depending on attention.

Two design notes for doing it properly here rather than naively:

**Deduplicate on failure class, not on occurrence.** A broken route throwing five hundred times should produce one task with a count, not five hundred tasks. The dedup key is the exception name plus the script plus the normalised route.

**A Tail Worker cannot tail itself.** Whatever handles the intake needs its own error path, and the honest one is a dead-letter queue rather than a second tail.

**Verdict: install. This is the highest-priority item in the entire series** — not because it is the most impressive, but because it makes an existing law mechanical instead of aspirational.

## Logpush and Log Explorer

Logpush pushes logs in near real time to storage or a SIEM. Log Explorer keeps them queryable in the dashboard.

The current position is that a production failure is diagnosed by re-running the thing that failed. That works for deterministic bugs and fails completely for the interesting ones — the intermittent transport fault, the payload that truncates only above a size threshold, the request that succeeded from one caller and 403'd from another. Those are diagnosed by reading what actually happened, and there is no record to read.

This build has already lost time to precisely that class. A dispatch payload containing a pipe character truncated silently and looked like an intermittent Apps Script fault for long enough to be misdiagnosed as one. With request logs, the pattern — every truncated payload contains a `|`, no exceptions — is visible in one query.

Pointing Logpush at the existing R2 bucket costs nothing and gives every future investigation a record to work from. With the Iceberg catalog from Part 2 on the same bucket, those logs become queryable with the same SQL as everything else.

**Verdict: install.** Cheap, and it converts "reproduce it" into "look it up".

## Workers Builds and gradual deployments

Deploys here go through `scripts/ship.mjs`, which is a real gate — it checks that HEAD matches origin, that the tree is committed, that the deploy runs from the repository root, and it fails on a list of accumulated invariants. That gate is load-bearing and should not be replaced.

What is missing is on either side of it.

**Workers Builds** runs the build on Cloudflare from a git push, so the deployed artifact is traceable to a commit on the account rather than to whatever was in a working directory. This complements the ship gate rather than replacing it: the gate decides whether a deploy is allowed, Builds records what was deployed.

**Gradual deployments** put a new version in front of a percentage of traffic before all of it. For a site with one origin and an agent population that ships several times a day, a bad render reaching ten percent of requests instead of all of them is a meaningful difference.

**Version metadata** is the small companion piece: a binding that lets a response name the version that served it. When something is wrong on the live site, the first question is always which deploy did this, and today that is answered by correlating timestamps.

**Verdict: version metadata and gradual deployments — install. Workers Builds — later**, and only alongside the existing gate, never instead of it.

## What this part does not recommend

**Do not move deploy authority to a git push.** The ship gate exists because deploys from the wrong directory produced a Functions-less build and a production outage, and because concurrent agents overwrote each other's shipped work. A push-to-deploy pipeline that bypasses those checks would reintroduce both failure classes with better ergonomics. Builds is welcome as a recorder. It is not welcome as the decider.

## Verdicts

| Product | What it replaces here | Verdict |
| --- | --- | --- |
| Tail Workers | A law about failure that depends on someone noticing | **install — first** |
| Logpush | Diagnosing production failures by re-running them | **install** |
| Log Explorer | The same, from the dashboard | **install** — with Logpush |
| Version metadata | Correlating timestamps to guess which deploy broke it | **install** |
| Gradual deployments | Every bad render reaching 100% of traffic immediately | **install** |
| Workers Builds | Nothing — the ship gate stays the decider | **later** — as a recorder only |

Next: [Part 8 — reaching private things](/a/cloudflare-os-xl-08-reaching-private-things).


## Sources

1. Tail Workers documentation — https://developers.cloudflare.com/workers/observability/logs/tail-workers/
2. Cloudflare Logpush documentation — https://developers.cloudflare.com/logs/logpush/
3. Workers versions and deployments documentation — https://developers.cloudflare.com/workers/configuration/versions-and-deployments/


---

# Cloudflare OS: the edge in front

slug: cloudflare-os-xl-06-the-edge-in-front · https://miscsubjects.com/a/cloudflare-os-xl-06-the-edge-in-front · category: systems · tags: cloudflare, rate-limiting, cache, snippets, security · updated 2026-08-06T03:28:35.996Z

*Part 6 of [Cloudflare OS XL](/a/cloudflare-os-xl), an inventory of the Cloudflare platform this build does not have installed.*

Every request to this site reaches a Worker. That is a design decision, and mostly a good one — the routing, the auth, the egress redaction and the render all live in code that can be read, tested and gated.

It also means that anything the Worker is asked to do, it does. There is no layer in front of it that decides a request is not worth running. The token-mint endpoint, the admin login, the objection intake and the article write path are all rate-limited by nothing at all. A caller who wants to hit `/api/articles/<slug>/objections` ten thousand times a minute will be served ten thousand Worker invocations.

Four products sit in that gap.

## The rate-limit binding

The rate-limit binding lets a Worker define a limit and check it inline. It is not a dashboard rule; it is a binding with a method.

```toml
[[unsafe.bindings]]
name = "MINT_LIMIT"
type = "ratelimit"
namespace_id = "1001"
simple = { limit = 20, period = 60 }
```

```js
const { success } = await env.MINT_LIMIT.limit({ key: callerFingerprint });
if (!success) return json({ error: 'rate_limited' }, 429);
```

The reason this belongs in the Worker rather than in a WAF rule is that the key can be anything the code knows. Not just an IP: the token id, the agent name, the article slug, the model making the call. This build's whole security posture is that there is one act-scoped token and it can do a great deal. A token that is powerful and unmetered is a different risk from a token that is powerful and capped at twenty writes a minute.

Priority order for this build: token mint, article write, objection intake, admin login.

**Verdict: install.** Small change, closes a real hole.

## Turnstile

Turnstile verifies that a visitor is human without a CAPTCHA. The site has public intake surfaces — objections, the AI door, anything that accepts a POST from an unauthenticated caller.

The tension worth naming: this build *wants* automated callers. Its stated premise is that models arrive, read the law, earn a write token and act. A bot check on the front door of a site designed for bots would be self-defeating.

So the useful placement is narrow. Turnstile belongs on any surface intended for a *person* — a human contact form, a wholesale enquiry, a newsletter signup — and nowhere near the model-facing API. If those human surfaces do not exist yet, neither does the need.

**Verdict: later.** Install it with the first human-facing form, not before.

## Snippets

Snippets run lightweight JavaScript at the edge to modify requests and responses, configured as a rule rather than deployed as a Worker.

The build already has a Worker whose entire job is to serve `robots.txt` on one route. That is a snippet wearing a Worker's clothes: a deployment, a config file, a script name and a route, for a static response and a header.

Redirects, canonical host enforcement, security headers, and small response rewrites are all in the same category. Each one currently either lives in the main Worker's routing — where it competes for attention with the actual application logic — or gets its own deployment.

**Verdict: install, for the trivia.** Move `robots.txt`, redirects and header injection out of Worker code. Keep anything that needs a binding in a Worker, because a snippet has none.

## Cache Reserve and deliberate caching

Article renders are cached today by whatever the response headers happen to say. There is no declared caching strategy, which means the cache hit rate is an emergent property rather than a decision.

Three separate things are available here and they are worth distinguishing:

**The Cache API** inside the Worker, for caching an assembled response — the rendered article, the sitemap, the feed — keyed however the code likes, and purged explicitly when the write path fires. This build already purges specific paths after a write, so the invalidation discipline exists; what is missing is the deliberate put.

**Tiered cache**, which makes a miss in one location check a nearer tier before going to origin. Configuration, not code.

**Cache Reserve**, which persists cached objects in R2 so they survive eviction. This suits the long tail — 1,171 articles of which a small number are read constantly and most are read rarely. The rarely-read ones are precisely the objects that fall out of edge cache and get regenerated from D1 every time.

**Verdict: install the Cache API and tiered cache. Cache Reserve: later**, once there is a measurement showing what the long tail actually costs.

## The three that are real products and wrong here

Being honest about "no" is the point of this series, so:

**Waiting Room** queues visitors when a site is oversubscribed. This site is not oversubscribed. Installing it would add a failure mode to solve a problem that does not exist.

**Load Balancing** distributes traffic across origins. There is one origin, and it is Cloudflare's own network. There is nothing to balance.

**Spectrum** proxies arbitrary TCP and UDP. Every protocol this build speaks is HTTP.

All three are good products. None of them have any business in this account, and a complete inventory that listed them as opportunities would be misleading by omission of the verdict.

## Verdicts

| Product | What it replaces here | Verdict |
| --- | --- | --- |
| Rate-limit binding | No limit at all on mint, write, objection or login | **install** |
| Snippets | A whole Worker deployed to serve `robots.txt` | **install** — for trivia only |
| Cache API + tiered cache | Cache behaviour as an emergent property | **install** |
| Cache Reserve | The long tail regenerating from D1 on every read | **later** — after measurement |
| Turnstile | Nothing yet; there is no human-facing form | **later** |
| Waiting Room | Nothing. The site is not oversubscribed | **no** |
| Load Balancing | Nothing. There is one origin | **no** |
| Spectrum | Nothing. Everything here is HTTP | **no** |

Next: [Part 7 — seeing what happened](/a/cloudflare-os-xl-07-seeing-what-happened).


## Sources

1. Workers rate limiting binding documentation — https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/
2. Cloudflare Snippets documentation — https://developers.cloudflare.com/rules/snippets/
3. Cloudflare Turnstile documentation — https://developers.cloudflare.com/turnstile/
4. Cloudflare Cache documentation — https://developers.cloudflare.com/cache/


---

# Cloudflare OS: images, video and media

slug: cloudflare-os-xl-05-media · https://miscsubjects.com/a/cloudflare-os-xl-05-media · category: systems · tags: cloudflare, images, stream, media, heroes · updated 2026-08-06T03:28:35.329Z

*Part 5 of [Cloudflare OS XL](/a/cloudflare-os-xl), an inventory of the Cloudflare platform this build does not have installed.*

Every article on this site has a hero image. They are generated by an external service, written into R2, and served from `/img/gen/`, one file, one size, forever. A hero on a phone downloads the same object as a hero on a desktop.

That is the whole media stack. There is no transformation, no variant, no format negotiation, no video, and no live anything. Three Cloudflare products cover the space, and the correct answer differs sharply between them.

## Images

Images is Cloudflare's platform for image pipelines: storage, transformation, and delivery through named variants.

The specific thing it fixes here is the one-file-one-size problem. Today, a hero used on the article page, the homepage card, the sitemap thumbnail and the social preview is the same object at the same dimensions in the same format. With Images, the original is stored once and requested at a variant:

```
https://imagedelivery.net/<hash>/<image-id>/hero
https://imagedelivery.net/<hash>/<image-id>/card
https://imagedelivery.net/<hash>/<image-id>/og
```

Format negotiation is automatic — a browser that accepts AVIF gets AVIF — and resizing happens at the edge rather than at generation time.

There is a second, less obvious fit. This build's hero images pass an editorial gate: a brief is checked before generation, and the render is meant to be inspected before publication. That gate currently operates on a prompt and a finished file. Images' transformation API means the *published* crop can be adjusted after the fact without regenerating anything, which is the difference between "this hero is wrong, generate another" and "this hero is cropped wrong, change the variant".

The migration is not free — existing `/img/gen/` URLs are stored in article metadata across 1,171 rows — but it is mechanical, and Images can serve from a remote source, so the transition does not have to be a big-bang re-upload.

**Verdict: install.** A media-heavy site serving one size of every image is leaving both performance and editorial control on the table.

## Stream

Stream is serverless video: upload or push live, get adaptive bitrate encoding, a player, and global delivery.

The build generates video. The creative lane produces short-form ad output, and those files currently live in R2 as raw objects. Serving an MP4 from R2 works, in the sense that a browser will play it. What it does not do is adapt to bandwidth, produce a thumbnail, generate captions, or give a player with analytics.

But the honest assessment is that this is not currently a bottleneck. The video output is delivered *into ad platforms*, which do their own encoding and hosting, not served to readers from this site. Stream would be solving a problem the build does not yet have.

The condition that would change it: the moment video appears on an article page or a wholesale catalogue page as something a reader watches, R2-as-video-host becomes the wrong answer immediately.

**Verdict: later.** Correct product, absent use case. Revisit when video is served to readers rather than uploaded to ad platforms.

## Realtime

Realtime covers live audio and video: RealtimeKit, an SFU for many-participant sessions, and TURN for connectivity.

This build has a voice lane. It records, transcribes, synthesises speech, and sends voice notes. Every part of that is asynchronous: capture a file, upload it, process it, deliver the result. Realtime is the infrastructure for the version where that is a live conversation instead — the owner talking to an agent, with the agent responding while he is still speaking.

That is a genuinely different product from what exists, and it is worth being clear that it is an ambition rather than a gap. The current voice lane is not broken. It is asynchronous by design and the design is fine.

**Verdict: no, for now.** Realtime is the answer to a question this build has not asked. Listing it as an opportunity because it is impressive would be exactly the brochure behaviour this series is meant to avoid.

## The one to do first

If only one thing in this part happens, it is Images, and the reason is not performance.

This build has an editorial law about heroes: the image shows the subject, not the research method. That law is enforced at the write path against the *brief*. What it cannot enforce is the presentation — whether the crop that ends up on the homepage card still shows the subject after being squeezed into a different aspect ratio. Named variants make the crop a declared property of the site rather than an accident of whatever dimensions the generator produced.

## Verdicts

| Product | What it replaces here | Verdict |
| --- | --- | --- |
| Images | One file, one size, one format for every hero on 1,171 articles | **install** |
| Stream | Ad video sitting in R2 as raw objects | **later** — when video is served to readers |
| Realtime | An asynchronous voice lane that works as designed | **no** — for now |

Next: [Part 6 — the edge in front of the Worker](/a/cloudflare-os-xl-06-the-edge-in-front).


## Sources

1. Cloudflare Images documentation — https://developers.cloudflare.com/images/
2. Cloudflare Stream documentation — https://developers.cloudflare.com/stream/
3. Cloudflare Realtime documentation — https://developers.cloudflare.com/realtime/


---

# Cloudflare OS: agents as infrastructure

slug: cloudflare-os-xl-04-agents-as-infrastructure · https://miscsubjects.com/a/cloudflare-os-xl-04-agents-as-infrastructure · category: systems · tags: cloudflare, agents, durable-objects, mcp, infrastructure · updated 2026-08-06T03:28:34.726Z

*Part 4 of [Cloudflare OS XL](/a/cloudflare-os-xl), an inventory of the Cloudflare platform this build does not have installed.*

This build already runs agents. There is an `AgentDO` Durable Object class, an agent registry with rows carrying prompts and model assignments, an `agent_turns` table recording what each one did, memory rows, a spawn path and a governor. That is a hand-rolled agent runtime, and it works.

The Agents SDK is Cloudflare's version of the same thing, and the interesting question is not "should this build have agents" — it has them — but which parts of the hand-rolled runtime are load-bearing and which are re-implementations of something the platform now provides.

## The Agents SDK

The SDK creates stateful agents with persistent memory, real-time WebSocket connections and scheduled tasks. Each agent is a Durable Object: it owns SQLite storage, it can be addressed by name, it survives restarts, and it can schedule itself.

Four things it provides that the current arrangement does not:

**Per-agent scheduling.** An agent can call `this.schedule(delay, 'methodName', payload)` and be woken up later. Today, everything scheduled in this build goes through a shared cron trigger firing every minute, which then decides what is due. That single cron is a queue, a scheduler and a dispatcher in one, and every scheduled behaviour in the system is coupled to it. Per-agent alarms decouple them.

**State as a first-class field.** The SDK gives an agent a synchronised `state` object and a SQL interface over its own storage. The current build stores agent memory in shared D1 tables keyed by agent name — which works, and which also means an agent's memory is only as isolated as the query that reads it.

**WebSockets with hibernation.** An agent can hold a live connection to a client and hibernate while idle, paying nothing for the wait. Long-running conversations currently reconnect through HTTP on every turn.

**A defined turn loop.** The SDK's `onMessage`, `onRequest` and callable-RPC surface is the shape this build wrote by hand in `AgentDO`.

The honest verdict is not "replace the agent runtime". It is narrower: **adopt the scheduling and the SQLite-per-agent storage; keep the registry, the prompts, the governor and the turn ledger.** Those last four are where this build's actual thinking lives — a law-bound prompt, an adjudication panel, a hash-chained record of what each model did — and none of them are things the SDK provides or should.

**Verdict: adopt in part.** Scheduling and per-agent state: yes. The registry and governance layer: keep what exists.

## Remote MCP servers with OAuth

This build's tool surface is already an MCP server. It runs locally, over stdio, through a bridge on the owner's machine, and it is reachable by exactly the clients configured on that machine.

Cloudflare hosts remote MCP servers as Workers, with `workers-oauth-provider` handling the authorization flow. The server becomes a URL. Any MCP client — Claude, an inspector, another agent, a partner's tooling — can attach to it by signing in, and the OAuth layer decides what each caller can see.

Three consequences for this build specifically.

**The bridge stops being a single point of failure.** Same argument as Part 3: capability that lives on a laptop is offline when the laptop is.

**Scope becomes structural rather than conventional.** This build has one act-scoped token that can edit articles and call every tool, plus a separate admin key. That is a deliberate design and it is documented. But it is enforced by the token check inside each handler, not by the protocol. An OAuth-fronted MCP server can present a different tool list to a different principal, which is a stronger form of the same idea.

**The build becomes attachable.** Its whole premise is that work is an object other agents can lease and act on. A public, authenticated MCP endpoint is the most direct expression of that premise available.

**Verdict: install.** This is the most on-thesis item in the entire series.

## Hibernatable WebSockets

Worth separating from the SDK, because it applies to Durable Objects generally and this build already has three classes.

A Durable Object holding a WebSocket normally stays in memory for the life of the connection. With the hibernation API, the DO can be evicted while the socket stays open, and is revived when a message actually arrives. The cost of an idle connection goes to approximately nothing.

The build has an obvious use: a live view of what agents are doing. Right now, watching the build work means polling an endpoint or reading a ledger tail. A hibernatable socket makes a push feed cheap enough to leave open indefinitely.

**Verdict: later.** Real, cheap, and not urgent until there is a surface that wants to watch.

## What this part does not recommend

**Do not rewrite `AgentDO` onto the SDK wholesale.** The temptation with a well-designed framework is to adopt all of it, and the parts of this build's agent layer that look like re-implementation are mostly not. The governor, the adjudication panel, the law-bound prompts and the turn ledger encode decisions that took months of corrections to arrive at. A framework migration that quietly drops them would be a regression wearing the clothes of an upgrade.

The rule to apply: adopt the platform where the platform provides *mechanism* — scheduling, storage isolation, connection handling. Keep what encodes *judgment*.

## Verdicts

| Product | What it replaces here | Verdict |
| --- | --- | --- |
| Agents SDK — scheduling | One shared cron firing every minute for all scheduled behaviour | **install** |
| Agents SDK — per-agent SQLite | Agent memory in shared D1 tables keyed by name | **install** |
| Agents SDK — turn loop, registry | The existing governor, prompts and turn ledger | **keep what exists** |
| Remote MCP server + OAuth | A stdio MCP bridge on one laptop | **install** |
| Hibernatable WebSockets | Polling an endpoint to watch agents work | **later** |

Next: [Part 5 — media](/a/cloudflare-os-xl-05-media).


## Sources

1. Cloudflare Agents SDK documentation — https://developers.cloudflare.com/agents/
2. Workers for Platforms documentation — https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/


---

# Cloudflare OS: running real code

slug: cloudflare-os-xl-03-running-real-code · https://miscsubjects.com/a/cloudflare-os-xl-03-running-real-code · category: systems · tags: cloudflare, containers, sandbox, agents, tooling · updated 2026-08-06T03:28:34.029Z

*Part 3 of [Cloudflare OS XL](/a/cloudflare-os-xl), an inventory of the Cloudflare platform this build does not have installed.*

There is a category of work this build does that a Worker cannot do. Run `ffmpeg` over a video. Convert a document with `pandoc`. Execute a Python script an agent just wrote. Run `git`, `npm`, `wrangler`, `magick`, `yt-dlp`. Drive a headless browser session that outlives a single fetch.

The directory has rows for all of those, and every one of them resolves to the same place: a bridge process on the owner's Mac. `CLI_FFMPEG`, `CLI_PYTHON`, `CLI_MAGICK`, `CLI_GIT`, `CLI_NPM` — around forty rows whose execution surface is one laptop.

This is the largest reliability liability in the system, and it is not subtle. If the laptop is asleep, a third of the build's capability is offline. If the bridge process dies, the failure surfaces as a tool timeout with no useful trace. Nothing about it is redundant, observable or reproducible. Three Cloudflare products move that work onto the network.

## Containers

Containers run alongside Workers: you give Cloudflare a Docker image, and a Worker can start an instance of it, route requests to it, and stop it. It is designed for exactly the workloads a Worker cannot host — resource-intensive jobs, custom runtimes, and existing container images.

The programming model matters here, because it is the reason this fits the build rather than sitting beside it. A container instance is addressed through a Durable Object. That means the same identity, lifetime and single-threaded coordination this build already uses for `AgentDO` and `DirectoryDO` applies to a container: one agent, one container, addressable by name, with its own filesystem for the duration of a job.

```toml
[[containers]]
class_name = "ToolRunner"
image = "./Dockerfile"
max_instances = 5
```

The migration path is direct. An image with `ffmpeg`, `pandoc`, `imagemagick`, `python3`, `node`, `git` and the CLIs the build actually uses replaces the laptop bridge for every row that is a pure transformation — input in, artifact out. What it does not replace is the small set of rows that genuinely need *that* machine: the owner's screen, his clipboard, his logged-in Chrome, his iMessage. Those are local by definition and stay local.

Splitting the CLI rows along that line is most of the work, and it is worth doing on its own terms even before a container exists, because right now those two very different kinds of capability are indistinguishable in the directory.

**Verdict: install.** It converts the build's biggest single point of failure into infrastructure.

## The Sandbox SDK

The Sandbox SDK is the layer above containers for one specific job: running code the build did not write. It gives a sandbox a filesystem, processes, a code interpreter and preview URLs, on top of Workers and Containers.

The distinction from a plain container is trust. A container image you built is a known runtime executing known commands. A sandbox is for the case where a model writes a script and something has to run it — with an isolated filesystem, a process boundary, and no access to the rest of the account.

This build has that case constantly and currently solves it by not solving it: a model that wants to compute something either asks for a tool row that already exists, or asks the owner's machine to run a shell command. Neither is code execution as a first-class capability. The second is code execution with the blast radius set to "the owner's laptop".

A sandbox also gives back something the current arrangement cannot: a preview URL. A model that writes a small web artifact can serve it and hand back a link, instead of writing a file somewhere and describing it.

**Verdict: install, after Containers.** It is the same substrate with a stricter contract, and the stricter contract is what untrusted code needs.

## Code Mode

Code Mode is the one entry in this series that repairs an existing, measured failure rather than adding capability.

The failure: this build exposes roughly nine hundred tool rows through a single stringly-typed dispatch surface. A model working through that surface spends most of its budget discovering contracts — what arguments does this row take, what does it return, what does the pipe delimiter do to a JSON payload. Measured on the `misc` agent, roughly fourteen of twenty calls in a session went to discovery rather than to work.

Code Mode inverts the loop. Instead of the model calling tools one at a time and reading each result, the tool surface is projected as a typed API, the model writes TypeScript against it, and that code runs in a sandbox with the results coming back once. Discovery happens at code-generation time, against types, rather than at runtime against error messages.

Two things about this build make the fit unusually good.

First, the tool surface is already machine-described. Every directory row has a description and a `when_to_use`, by law. That is the raw material a typed API is generated from, and it already exists.

Second, the failure this fixes is documented as a tool-surface defect rather than an agent defect. The cheap agent is not bad at its job; it is spending its context on a contract-discovery problem the surface creates. Code Mode is the fix aimed at the layer that permitted it, which is the standard this build holds itself to everywhere else.

**Verdict: install.** This is a repair, not an enhancement.

## The interaction with what is already here

Worth naming, because it is easy to miss: Containers, the Sandbox SDK and Code Mode all sit on Durable Objects, and this build already runs three Durable Object classes in production. The hard part of adopting them — understanding DO identity, lifetime and addressing — is not new work here. It is the same model already in use for the directory and the agent memory.

## Verdicts

| Product | What it replaces here | Verdict |
| --- | --- | --- |
| Containers | ~40 CLI rows executing on the owner's laptop | **install** |
| Sandbox SDK | Model-written code run by a shell command on that laptop | **install** — after Containers |
| Code Mode | 14 of 20 agent calls spent discovering tool contracts | **install** — this is a repair |
| Local bridge for screen, clipboard, Chrome, iMessage | Nothing. These are local by definition | **keep local** |

Next: [Part 4 — agents as infrastructure](/a/cloudflare-os-xl-04-agents-as-infrastructure).


## Sources

1. Cloudflare Containers documentation — https://developers.cloudflare.com/containers/
2. Cloudflare Sandbox SDK documentation — https://developers.cloudflare.com/sandbox/
3. Workers for Platforms documentation — https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/


---

# Cloudflare OS: the ledger as a table

slug: cloudflare-os-xl-02-ledger-as-a-table · https://miscsubjects.com/a/cloudflare-os-xl-02-ledger-as-a-table · category: systems · tags: cloudflare, pipelines, r2, ledger, analytics · updated 2026-08-06T03:28:33.400Z

*Part 2 of [Cloudflare OS XL](/a/cloudflare-os-xl), an inventory of the Cloudflare platform this build does not have installed.*

The central claim of this build is that nothing is ever overwritten and every action appends a hash-chained audit row. That claim is true. The rows exist, in a D1 database called `loop-shared-events` and in R2 as receipt files.

Then someone asks a question of it — how many outbound sends went to a domain whose MX record failed, per week, since May — and the answer is produced by pulling files and counting them in a script. The ledger is a record. It is not yet a table anybody can query.

Five products close the distance between those two things.

## Pipelines

Pipelines is Cloudflare's streaming ingest: data arrives over HTTP or from a Worker binding, is transformed with SQL, and is delivered to R2 as Apache Iceberg tables or as Parquet and JSON files. It is in open beta.

Today, every ledger append is a D1 `INSERT` executed inside the request that caused it. That has three costs. It puts a write in the hot path of the thing being recorded. It makes the ledger's throughput a function of D1's write throughput. And it produces rows, not columns — which is why analytical questions are answered by export-and-count.

With a pipeline, the Worker writes an event to a binding and returns. The pipeline batches, transforms and lands it in R2 in a columnar format. The record is still append-only and still hash-chained; it is simply stored as something a query engine can read.

The natural first candidates here are the three highest-volume event streams: agent turns, tool invocations, and outbound send receipts.

**Verdict: install, for agent turns first.** It is beta, so it belongs on the stream where a gap would be survivable, not on the audit chain.

## R2 Data Catalog and R2 SQL

R2 Data Catalog is a managed Apache Iceberg catalog built into an R2 bucket. R2 SQL is a distributed SQL engine that queries it. Together they are the reason the previous section says "Iceberg" rather than "Parquet files in a bucket": Iceberg gives the pile of files a schema, a snapshot history and a table identity, and R2 SQL means you do not have to bring your own engine to read it.

Enabling the catalog on an existing bucket is one command.

```
wrangler r2 bucket catalog enable miscsubjects-ledger
```

What this changes for this build is the nature of an audit. The audit chain is the build's trust mechanism; it is what makes the claim "nothing is ever overwritten" checkable rather than asserted. But a trust mechanism that can only be verified by a bespoke script is verified by whoever wrote the script. A ledger as an Iceberg table can be queried by anyone with the credential, including a model, including an outside auditor, with a plain `SELECT`.

There is a second, quieter benefit. Time-travel is a property of Iceberg, not something this build would have to implement: the table can be read as of a snapshot. "What did the ledger say on 3 August" stops being a question about backups.

**Verdict: install.** Low cost, and it converts an existing asset into a queryable one without moving it out of R2.

## R2 event notifications

An object lands in R2 and a message appears on a queue. That is the whole feature, and it is missing from a build that has three queues already.

Right now, assets get processed because a cron woke up and looked. Generated hero images, ArcAds output, absorbed repositories, uploaded references — each of those arrives in a bucket and then waits for a scheduled sweep to notice. The sweep runs every minute, which is fast enough to feel instant and is still the wrong mechanism: it polls whether or not anything happened, and it cannot tell you *why* it processed something.

```
wrangler r2 bucket notification create miscsubjects-store --event-type object-create --queue loop-tasks
```

With that, the arrival of the object *is* the trigger. The queue message carries the bucket, the key and the event type, so the consumer knows exactly what changed rather than diffing a listing.

**Verdict: install.** It is one command per bucket and it deletes polling code.

## Analytics Engine

Analytics Engine accepts unlimited-cardinality analytics written from a Worker and queried with SQL. Writes are non-blocking and effectively free; you get one dataset binding and you write data points with blobs, doubles and an index.

```toml
[[analytics_engine_datasets]]
binding = "METRICS"
dataset = "loop_metrics"
```

```js
env.METRICS.writeDataPoint({
  blobs: [toolName, modelId, agentName, outcome],
  doubles: [latencyMs, tokensIn, tokensOut, costUsd],
  indexes: [agentName],
});
```

This build already tries to answer cost and latency questions — there is a `COST_REPORT` row, a governor, and per-model accounting. Those work by reading the ledger back and aggregating it, which means the cost of asking a cost question scales with the size of the ledger.

Analytics Engine is the correct tool for that specific class of question because it is designed for high-cardinality dimensions. Per-tool, per-model, per-agent, per-outcome, forever, at a write cost that does not compete with the request. The ledger keeps being the record of what happened; Analytics Engine becomes the record of how much it cost and how long it took.

The one constraint worth knowing before adopting it: it is a metrics store, not an event store. Data points are sampled at high volume and are not the audit trail. Do not put anything in it that has to be exact.

**Verdict: install.** It answers the cost question this build keeps asking, and it does not compete with the ledger for that role.

## What this part does not recommend

**Do not move the audit chain off D1.** The hash chain's value is that each row commits to the previous one at write time, inside a transaction, in the same request that performed the act. Streaming it through a batching pipeline first would put a gap between the act and the commitment, and the gap is exactly what the chain exists to close. Pipelines belongs on the high-volume observational streams. The chain stays where it is.

## Verdicts

| Product | What it replaces here | Verdict |
| --- | --- | --- |
| Pipelines | Per-row D1 inserts in the hot path for high-volume streams | **install** — agent turns first |
| R2 Data Catalog | A ledger auditable only by a bespoke script | **install** |
| R2 SQL | Export-and-count in a local script | **install** — with the catalog |
| R2 event notifications | A cron sweep that polls buckets every minute | **install** |
| Analytics Engine | Cost and latency questions answered by re-reading the ledger | **install** |
| Pipelines *for the audit chain* | Nothing — it would weaken it | **no** |

Next: [Part 3 — running real code](/a/cloudflare-os-xl-03-running-real-code).


## Sources

1. Cloudflare Pipelines documentation — https://developers.cloudflare.com/pipelines/
2. R2 Data Catalog documentation — https://developers.cloudflare.com/r2/data-catalog/
3. R2 SQL documentation — https://developers.cloudflare.com/r2-sql/
4. Workers Analytics Engine documentation — https://developers.cloudflare.com/analytics/analytics-engine/


---

# Cloudflare OS: search and retrieval

slug: cloudflare-os-xl-01-search-and-retrieval · https://miscsubjects.com/a/cloudflare-os-xl-01-search-and-retrieval · category: systems · tags: cloudflare, vectorize, retrieval, d1, infrastructure · updated 2026-08-06T03:28:32.619Z

*Part 1 of [Cloudflare OS XL](/a/cloudflare-os-xl), an inventory of the Cloudflare platform this build does not have installed.*

This build holds 1,171 published articles, several thousand atomized claims, a source ledger, an audit chain, a lead table and a directory of roughly nine hundred callable rows. Every one of those is searched the same way: a SQL `LIKE '%term%'` against D1, or an exact-key lookup in KV.

That works when you know the word. It fails completely when you know the idea. Ask this build "which articles argue that a gate must measure the object it claims to measure" and there is no query that answers it, because the sentence that makes the argument may not contain any of those words. The corpus knows the answer. The build cannot reach it.

Three Cloudflare products close that, and none of them are installed.

## Vectorize

Vectorize is Cloudflare's vector database, bound directly into a Worker. You create an index with a fixed dimensionality and metric, write vectors with metadata, and query by nearest neighbour.

```
wrangler vectorize create loop-corpus --dimensions=768 --metric=cosine
```

```toml
[[vectorize]]
binding = "CORPUS"
index_name = "loop-corpus"
```

The embedding model is already here — Workers AI is bound on both the Pages project and the sibling Worker, and `@cf/baai/bge-base-en-v1.5` produces 768-dimension vectors without leaving the account. So the whole loop is inside Cloudflare: read the article from D1, embed it with the AI binding, upsert into Vectorize with the slug and claim id as metadata, query it from the same Worker.

What it changes here, concretely:

- **Claim-level retrieval.** The unit is not the article, it is the claim. Every claim already has an id, a tier and a text field. Embedding claims rather than articles means a search returns *the specific assertion*, which is the addressable object this build is built around, and metadata filtering lets a query say "only claims at tier `human` or `rct`".
- **Duplicate detection at the write path.** Before an article publishes, the write path could ask whether any existing claim is within a cosine distance of the incoming one. The corpus has grown by swarm passes; some of it says the same thing twice in different words, and there is currently no mechanism that could know.
- **Lead matching.** The lead table and the content corpus are unrelated tables today. With both embedded, "which article should this clinic receive" becomes a query rather than a guess.
- **The directory.** Nine hundred tool rows with descriptions is exactly the retrieval problem vector search is for. An agent looking for the right capability currently reads a list.

Vectorize is metadata-filterable and namespace-partitioned, so one index can hold claims, articles, leads and directory rows without them contaminating each other's results.

**Verdict: install.** This is the single highest-value absent product in the account, and everything it needs — Workers AI, D1, the claim structure — is already in place.

## AI Search, formerly AutoRAG

The product this build's directory still refers to as AutoRAG has been renamed Cloudflare AI Search. It is the managed version of the pipeline described above: point it at an R2 bucket, and Cloudflare crawls it, chunks it, embeds it, stores the vectors, keeps them in sync as the bucket changes, and exposes both a raw `search` and an `aiSearch` that returns a generated answer with citations.

The difference from Vectorize is ownership of the pipeline. With Vectorize you write the chunker, choose the model, handle re-embedding on edit, and own the freshness problem. With AI Search, Cloudflare owns all of it and you own a bucket.

For this build the two are not competitors, they are different jobs:

- **AI Search** suits the *reference* material — the vendor documentation absorbed into R2, the Grok docs pulled verbatim from `llms.txt`, the Workspace and Wrangler surfaces, the absorbed repositories. That content is written once, read often, and nobody needs claim-level addressability into it. Turning that bucket into an AI Search index gives every agent a documentation oracle with citations for near zero code.
- **Vectorize** suits the *corpus* — articles and claims — because the retrieval unit has to be the claim id, the metadata filter has to be the evidence tier, and the write path has to control exactly when a vector is refreshed.

There is also a third property worth noting: AI Search exposes an MCP server. The documentation oracle becomes a tool any model client can attach to without this build writing the bridge.

**Verdict: install, for the reference bucket only.** Do not point it at the article corpus; that content needs the control Vectorize gives.

## D1 read replication and the Sessions API

This one is not retrieval, it is the same problem from the other side: the corpus is read globally and written from one place.

D1 supports read replicas. Replicas are created and placed automatically; the application opts in per request by starting a *session*, which is what preserves sequential consistency — read-your-writes — across a set of queries that might otherwise land on a replica that has not caught up yet.

```js
const session = env.DB.withSession('first-primary');
const { results } = await session.prepare('SELECT ...').all();
// bookmark travels with the response; the next request resumes the session
```

The shape of this build's traffic is exactly the shape read replication is for. The content spine is read on every page render, every API article fetch, every sitemap build, every feed. It is written by a handful of agents. Today every one of those reads crosses to wherever the primary lives.

The cost of adopting it is real but bounded: read paths must be audited to decide which ones need read-your-writes and which are happy with an eventually consistent replica. The article render is happy. The write path's own read-back after a PUT is not, and must carry the bookmark.

**Verdict: install, after an audit of the read paths.** It is a configuration change and a code change in one place, and it is free.

## What this part does not recommend

There is a fourth option that looks adjacent and is not: putting the corpus in an external vector store and reaching it over HTTP. It would work. It would also put a network hop, a second vendor, a second credential and a second failure mode into the hot path of every page render, in exchange for nothing this account cannot already do inside its own bindings. The reason to run on one platform is that the bindings do not go down separately from the Worker.

## Verdicts

| Product | What it replaces here | Verdict |
| --- | --- | --- |
| Vectorize | `LIKE '%term%'` over 1,171 articles; no claim-level retrieval at all | **install** |
| AI Search (AutoRAG) | Agents reading absorbed vendor docs by grepping files | **install** — reference bucket only |
| D1 read replication | Every global read crossing to the primary | **install** — after read-path audit |
| External vector store | Nothing. It adds a vendor and a hop | **no** |

Next: [Part 2 — the ledger as a queryable table](/a/cloudflare-os-xl-02-ledger-as-a-table).


## Sources

1. Cloudflare Vectorize documentation — https://developers.cloudflare.com/vectorize/
2. Cloudflare AI Search documentation — https://developers.cloudflare.com/autorag/
3. Cloudflare D1 documentation — https://developers.cloudflare.com/d1/


---

# Cloudflare OS: what this build has not installed

slug: cloudflare-os-xl · https://miscsubjects.com/a/cloudflare-os-xl · category: systems · tags: cloudflare, infrastructure, inventory, workers, audit · updated 2026-08-06T03:28:31.965Z

This build runs on Cloudflare. Not "hosted on" — runs on. The site is Cloudflare Pages. The content spine is D1. The ledger is D1 and R2. The agent memory is Durable Objects with SQLite. The models are called through Workers AI and the AI Gateway. Long jobs are Workflows. Fan-out is Queues. Page-fetching is Browser Rendering. Outbound mail is a `send_email` binding. Secrets are in Secrets Store.

That is thirteen Cloudflare products, bound and in production, doing real work every hour.

It is also less than a third of what the account can hold.

This series is the inventory of the rest: every Cloudflare product that is not installed here, what it would actually do for this specific build, and — this matters more — an honest verdict on whether it should be installed at all. A list of everything a vendor sells is a brochure. This is not that. Several entries below end in "no", and the reasons are given.

## What is already here

Verified against the account and every `wrangler.toml` in the repo on 6 August 2026, not from memory:

| Product | State in this build |
| --- | --- |
| Workers + Pages | The site, the API, the admin surface |
| D1 | 4 databases: content spine, shared events, storage index, previews |
| KV | 1 namespace: settings, freeze flags, snapshots |
| R2 | 2 buckets: ledger, store |
| Durable Objects (SQLite) | 3 classes: DirectoryDO, ExpertDO, AgentDO |
| Queues | 3: loop-tasks, loop-ingest, loop-ingest-dlq |
| Workflows | 10, across two Workers |
| Workers AI | Bound on Pages and on the sibling Worker |
| AI Gateway | Every model call routes through it |
| Browser Rendering | `MYBROWSER` binding on the sibling Worker |
| Email (outbound) | `send_email` binding |
| Secrets Store | Meta credentials, bound by reference |
| Service bindings | STORE, META_BRIDGE |
| Cron triggers | Every minute, and 04:00 daily |
| Observability | Enabled on every Worker |

## What is empty

Four products were checked directly against the account rather than inferred from config. All four returned nothing:

```
wrangler vectorize list   →  You haven't created any indexes on this account.
wrangler hyperdrive list  →  (empty)
wrangler pipelines list   →  No pipelines found.
wrangler containers list  →  No containers found.
```

Everything else in this series is absent by config scan: no binding, no route, no reference anywhere in the repo that does more than mention the name in a documentation row.

That distinction is worth stating plainly, because this build has a directory of roughly nine hundred callable rows and several of them *describe* products that are not installed. A row that documents Turnstile is not Turnstile. The inventory below counts bindings and provisioned resources, not documentation.

## The ten parts

**[Part 1 — Search and retrieval](/a/cloudflare-os-xl-01-search-and-retrieval)**
Vectorize, AI Search (formerly AutoRAG), and D1 read replication. The corpus is 1,171 articles and is queried with SQL `LIKE`. This is the largest single gap in the build.

**[Part 2 — The ledger as a queryable table](/a/cloudflare-os-xl-02-ledger-as-a-table)**
Pipelines, R2 Data Catalog, R2 SQL, R2 event notifications, Analytics Engine. Audit questions are currently answered by pulling files and counting in a script.

**[Part 3 — Running real code](/a/cloudflare-os-xl-03-running-real-code)**
Containers, the Sandbox SDK, and Code Mode. Every heavy tool in this build shells out to the owner's laptop. That is the single biggest reliability liability in the system.

**[Part 4 — Agents as infrastructure](/a/cloudflare-os-xl-04-agents-as-infrastructure)**
The Agents SDK, remote MCP servers with OAuth, hibernatable WebSockets. Two Durable Object classes already do a hand-rolled version of this.

**[Part 5 — Media](/a/cloudflare-os-xl-05-media)**
Images, Stream, Realtime. Hero images are generated externally and stored as raw R2 objects with no variants.

**[Part 6 — The edge in front of the Worker](/a/cloudflare-os-xl-06-the-edge-in-front)**
Snippets, the rate-limit binding, Turnstile, Cache Reserve. The admin key and the token-mint endpoint are rate-limited by nothing.

**[Part 7 — Seeing what happened](/a/cloudflare-os-xl-07-seeing-what-happened)**
Logpush, Log Explorer, Tail Workers, Workers Builds, gradual deployments. A Tail Worker is the missing mechanical link between "it broke" and "a task row exists".

**[Part 8 — Reaching private things](/a/cloudflare-os-xl-08-reaching-private-things)**
Hyperdrive, Workers VPC, Tunnel, mTLS certificates. The honest fix for the local bridge.

**[Part 9 — The security surface](/a/cloudflare-os-xl-09-the-security-surface)**
Access, WAF custom rules, inbound Email Routing, API Shield. Only half of email is installed.

**[Part 10 — Hosting other builds](/a/cloudflare-os-xl-10-hosting-other-builds)**
Workers for Platforms, Terraform, Radar. The ceiling: the point where this stops being one site.

## The two that fix existing failures

Everything in this series is new capability except two entries, and those two are different in kind because they close failure classes already written into this build's failure vault.

**Tail Workers** (Part 7). When a Worker throws, the trace goes to observability and a human has to go look. A Tail Worker is a Worker that consumes another Worker's invocation logs, so a thrown exception can *append a task row naming the failure class* without anyone reading a dashboard. This build's central rule is that a failure becomes a child task rather than a sentence in a report. Right now that rule depends on an agent noticing. A Tail Worker makes it mechanical.

**Code Mode** (Part 3). A model calling nine hundred single-purpose tools spends most of its calls discovering contracts rather than doing work — measured on the `misc` agent, roughly fourteen of twenty calls. Code Mode inverts it: the model writes TypeScript against a generated API and runs it in a sandbox, so discovery happens once, at codegen time, instead of once per call.

Those two are not enrichment. They are repairs.

## How to read the verdicts

Each part ends with a table of the same three columns: the product, what it would replace here, and a verdict of **install**, **later**, or **no**. "No" is used honestly — Waiting Room, Load Balancing and Spectrum are all real products that this build has no business installing, and saying so is more useful than listing them as opportunities.


## Sources

1. Cloudflare D1 documentation — https://developers.cloudflare.com/d1/
2. Cloudflare Queues documentation — https://developers.cloudflare.com/queues/
3. Workers for Platforms documentation — https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/


---

# Cloudflare OS: An LLM Operating System Built on One Account, One Deployment, One Grammar

slug: the-unified-loop · https://miscsubjects.com/a/the-unified-loop · tags: infrastructure, cloudflare, mcp, agents, architecture, canonical · updated 2026-07-28T05:37:33.640Z

# Cloudflare OS: An LLM Operating System Built on One Account, One Deployment, One Grammar

This article is the canonical description of the miscsubjects.com build. It is written for a reader who has never seen it before. It assumes nothing. It defines every term. It links every claim to a source. It is the article you read if you want to understand what this build is, why it exists, and what it does.

---

## The problem this solves

AI systems today are opaque. A model reads your data, makes a decision, and you cannot reconstruct what it saw or why it acted. If the answer is wrong, you cannot trace the error. If the answer is right, you cannot prove it.

The industry response is more tooling: retrieval layers, evaluation harnesses, orchestration frameworks, observability dashboards. Each tool adds a layer. Each layer adds a cost. Each cost adds a failure mode. The stack grows. The opacity remains.

This build takes the opposite approach. It removes the stack. It replaces it with one grammar.

---

## The grammar: one object, one address, one receipt

The grammar is OIP — the Object Invocation Protocol. It is the only protocol in the build. It governs every object, every invocation, every receipt.

An OIP object states the work, the input, the authority, the invocation route, the runner, the proof requirement, the receipt, the replay path, and the repair path. A model operates OIP by resolving an object, reading the object contract, invoking the object route, and returning the receipt.

The OIP unit is the work object. The OIP proof is the receipt. The OIP loop is object, invoke, ledger, receipt, replay, repair.

Every article is an OIP object. Every claim is an OIP object. Every source is an OIP object. Every capability is an OIP object. Every event in the ledger is an OIP object. The operator's Mac is an OIP object. The Stripe charge is an OIP object. The text message is an OIP object.

One grammar. One address. One receipt.

---

## The loop: how the system runs

The system runs in a short, receipted loop. Six steps. No retrieval layer. No evaluation harness. No orchestration framework.

**Step 1: Resolve.** A model receives a plain-language request. It queries the object grammar: `?ask=<plain language>` or `?key=<KEY>`. The grammar returns the matching object.

**Step 2: Contract.** The object carries a machine-readable contract. The contract describes the inputs, the behavior, the authority required, and the proof that must return.

**Step 3: Scope.** The model checks the credential. `?explain=1&share=TOKEN` reports what the credential permits. No credential, no invocation.

**Step 4: Invoke.** The model invokes the object. The object runs. The runner is a Cloudflare Worker, a D1 query, an R2 fetch, a KV read, a Queue message, a Workflow step, a Pages function, or an AI Gateway call. The runner is one of six Cloudflare primitives.

**Step 5: Ledger.** The invocation is recorded in the ledger. The ledger is append-only. The ledger is signed. The ledger is immutable.

**Step 6: Receipt.** The receipt is returned. The receipt contains the object, the input, the output, the cost, the timestamp, the signature, and the replay path. The receipt is the proof.

---

## The infrastructure: Cloudflare as the LLM OS

The build runs on one Cloudflare account. One deployment. One database. One operator.

The Cloudflare primitives used:

- **Workers**: The compute layer. Every capability is a Worker. Every Worker is a function. Every function is a receipt.
- **D1**: The database. One SQLite database at the edge. Every article, every claim, every source, every capability, every event.
- **R2**: The object store. Every image, every file, every artifact. Immutable. Hash-chained.
- **KV**: The key-value store. Session state, rate limits, feature flags.
- **Pages**: The frontend. Static. Fast. Global.
- **AI Gateway**: The model router. Every model call is logged. Every call has a cost. Every cost is receipted.
- **Workflows**: The orchestration. Multi-step operations. Durable. Receipted.
- **Vectorize**: The vector database. Semantic search. Embeddings. Similarity.
- **Queues**: The message queue. Async processing. Receipted delivery.
- **Email Routing**: The email layer. Inbound and outbound. Receipted.

One account. One deployment. One database. One operator.

---

## The evidence graph: how belief is computed

The build does not trust models. It verifies them.

Every model examination of an object is stored as a signed record. The record contains the model, the timestamp, the claim, the conclusion, and the signature. The record is immutable.

The evidence graph is the set of all signed records for all objects. The graph is queryable. The graph is public. The graph is auditable.

Belief is computed from the graph. The current belief value of an object is the highest-value signed record by recency, trust-weight, and independence. The computation is deterministic. The computation is public. The computation is receipted.

---

## The sources: where the evidence lives

Every claim in the build carries a source. The sources are public. The sources are queryable.

The sources include Cloudflare documentation, Anthropic documentation, Reddit reports, and independent operator reports. Each source is attached to a specific claim. Each source is hash-chained. Each source is immutable.

The sources are the evidence. The evidence is public. The public can audit.

---

## The objections: what this cannot do

This build cannot prove that a model's conclusion is correct. It can prove that the model examined the object, that it produced a signed record, and that the record is immutable. It cannot prove that the conclusion is true.

This build cannot prevent a model from hallucinating. It can prove that the model read the governing document. It cannot prove that the model understood it.

This build cannot scale to billions of objects without compaction. The append-only ledger grows. The graph resolution slows. The working set expands. The tradeoff is documented. The tradeoff is public.

---

## The verdict

This build is a working prototype of a different way to organize AI systems. Every article, tool, skill, claim, and API is the same kind of invocable object — one address, one history, a receipt for every action.

The build is not a product. It is a protocol. The protocol is public. The protocol is queryable. The protocol is auditable.

The build is not a company. It is one operator. The operator is public. The operator is accountable. The operator is the build.

The build is not a stack. It is one grammar. The grammar is public. The grammar is minimal. The grammar is enough.

---

## The links

- The protocol: https://miscsubjects.com/a/oip-spec
- The system map: https://miscsubjects.com/api/articles/system-map
- The proof: https://miscsubjects.com/api/proof
- The ledger: https://miscsubjects.com/admin/ledger
- The read gate: https://miscsubjects.com/a/read-gate
- Proof of coverage: https://miscsubjects.com/a/proof-of-coverage
- The evidence graph: https://miscsubjects.com/a/object-ledger-evidence-graph-spec

Every link is live. Every link is queryable. Every link is a receipt.


## Sources

1. Model Context Protocol — Tools — https://modelcontextprotocol.io/docs/concepts/tools
2. Anthropic — Tool search tool — https://docs.claude.com/en/docs/agents-and-tools/tool-use/tool-search-tool
3. Cloudflare D1 — https://developers.cloudflare.com/d1/
4. Cloudflare Pages Functions — Routing — https://developers.cloudflare.com/pages/functions/routing/
5. Cloudflare Workers — Limits — https://developers.cloudflare.com/workers/platform/limits/
6. OpenAI — Assistants overview — https://platform.openai.com/docs/assistants/overview
7. LangChain — Introduction — https://python.langchain.com/docs/introduction/
8. CrewAI documentation — https://docs.crewai.com/
9. LlamaIndex documentation — https://docs.llamaindex.ai/en/stable/
10. Hacker News — zc2610, 2026-04-14 — https://news.ycombinator.com/item?id=47767067
11. Hacker News — ramesh31, 2025-06-06 — https://news.ycombinator.com/item?id=44197352
12. Hacker News — justvugg, 2025-12-23 — https://news.ycombinator.com/item?id=46370218
13. Hacker News — DarmokJalad1701, 2025-06-04 — https://news.ycombinator.com/item?id=44186682
14. Cloudflare Workers AI — Pricing — https://developers.cloudflare.com/workers-ai/platform/pricing/
15. Operator report: tool definitions alone eat 50K-100K tokens — https://x.com/dani_avila7/status/1993134510364389555
16. Seven MCP servers burned 67k tokens before a keystroke — https://x.com/aakashgupta/status/2011664388424454262
17. Load nothing until it is needed — https://x.com/dzhng/status/2029518820872945889
18. Fifty definitions and the agent struggles to pick — https://x.com/milvusio/status/2047014068888703391
19. Practical ceiling of about forty MCP tools in Cursor — https://www.reddit.com/r/mcp/comments/1jbiqex/max_mcp_tool_limits_hit_in_cursor/
20. Bloated prompt, unstable model — https://www.reddit.com/r/mcp/comments/1jimv3u/whatll_hapen_if_there_has_a_lots_of_tool_in_mcp/
21. Specifications take space and degrade every tool — https://www.reddit.com/r/ClaudeAI/comments/1ka3jao/why_is_claude_is_so_good_at_tool_calling/mpz8b2g/
22. opencode #35376: 40k-70k tokens of definitions loaded upfront — https://github.com/anomalyco/opencode/issues/35376
23. copilot-cli #4189: reported cost 20x the real deferred cost — https://github.com/github/copilot-cli/issues/4189
24. hermes-agent #67273: schemas are 83.1% of the token budget — https://github.com/NousResearch/hermes-agent/issues/67273
25. The token claim is an implementation detail of the harness — https://news.ycombinator.com/item?id=48331540
26. Progressive disclosure already solves it — https://news.ycombinator.com/item?id=47719249
27. Prompt caching turns it into a fixed penalty — https://news.ycombinator.com/item?id=47160970


---

# Cloudflare Access authenticates the edge, not your application

slug: cloudflare-os-access · https://miscsubjects.com/a/cloudflare-os-access · tags: cloudflare, architecture, security, cloudflare-os, access, zero-trust, authentication · updated 2026-07-26T06:20:14.182Z

Cloudflare Access sits between a request and an origin. For a person, it turns an application URL into an identity check: Cloudflare redirects the browser to an identity provider, applies an Access policy, and issues a signed session token. For a machine, there is no login page. It must send a service credential on the first request, and the Access policy must explicitly accept that credential.

The distinction that decides the design:

> Access proves that a request satisfied an edge policy. Your application still decides what that authenticated principal may do.

A service token can pass Access and still carry no human identity. A Bypass rule can make a path reachable while removing Access authentication and Access logging from that path. Deleting the application does not prove the service token was deleted, and deleting the token does not prove the application or policy disappeared. Those are separate objects with separate list and delete operations.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## The request path, without product names hiding the mechanics

| Stage | Human request | Machine request |
| --- | --- | --- |
| 1. Request arrives | Browser requests the protected hostname and path | HTTP client requests the same URL |
| 2. Access checks credential | Looks for a valid `CF_Authorization` cookie | Looks for service-token headers or another non-human credential |
| 3. No valid credential | Redirects to the Access login flow | Usually a `302` the client cannot use, or `401`/`403` when Service Auth handling is configured |
| 4. Policy evaluation | Allow, Block, Bypass, or a more specific rule | Service Auth, mTLS, or Bypass |
| 5. Origin request | Cloudflare forwards `Cf-Access-Jwt-Assertion` | Cloudflare forwards an application JWT after service authentication |
| 6. Origin authorization | Verify signature, issuer and audience; map `email` or `sub` to an app role | Verify the same fields; map `common_name` to a synthetic machine principal |

Access is not an origin firewall. Unless the origin is connected only through Cloudflare Tunnel or otherwise restricted to Cloudflare, an attacker may try to reach it directly and avoid the Access layer. Even when every request must pass Cloudflare, the origin still verifies the JWT. Cloudflare's application-token reference is blunt: validation of the header alone is insufficient because an unverified header can be spoofed.

## Create one self-hosted application in the dashboard

Prerequisites: a Cloudflare account, a Zero Trust organization, a domain on Cloudflare, and an identity provider. The built-in one-time PIN flow is enough for a small first deployment; an organization using group rules should connect its existing SAML or OIDC provider and confirm the exact group claim before writing policy.

Current dashboard path:

1. Open **Zero Trust**.
2. Go to **Access controls** → **Applications**.
3. Select **Add an application**.
4. Choose **Self-hosted**.
5. Set **Application name**.
6. Under **Session Duration**, choose how long the application JWT remains valid.
7. Under **Add public hostname**, enter **Subdomain**, **Domain**, and optional **Path**. A path makes the Access application narrower than the hostname.
8. Under **Access policies**, create or attach a policy.
9. Choose the identity providers shown on the login page.
10. Save, then test one allowed identity and one denied identity before widening the selectors.

Access applications are deny-by-default. Creating the hostname without an Allow or Service Auth policy does not grant anyone access.

The four policy actions do different jobs:

| Action | What a match means | Correct use | Dangerous misunderstanding |
| --- | --- | --- | --- |
| **Allow** | The request may continue after identity authentication | People selected by email, IdP group, country, device posture, or another identity rule | “Not blocked” does not mean allowed; unmatched users remain denied |
| **Block** | The matching request is denied | Carve a narrow denial out of a broader Allow rule | A Block rule alone does not make everyone else allowed |
| **Bypass** | Access enforcement is disabled for the matching traffic | A deliberately public webhook or health path whose own exposure is accepted | No Access identity, controls, or Access logs remain on that path |
| **Service Auth** | A non-IdP credential may pass | Service tokens or mutual TLS for automation | A service token is not a human and may not have `sub` or `email` |

Policy order and selectors matter. Test with the policy tester, then make real HTTP requests. A green dashboard object is configuration evidence, not traffic evidence.

## The same application and policy through the REST API

Use a Cloudflare API token scoped to **Access: Apps and Policies Write**. Keep the account id and API token in environment variables; neither belongs in shell history, an article, or a CI log.

```sh
curl -sS -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/apps" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "content-type: application/json" \
  --data '{
    "name": "admin surface",
    "type": "self_hosted",
    "domain": "admin.example.com",
    "session_duration": "8h",
    "app_launcher_visible": false,
    "service_auth_401_redirect": true
  }'
```

Capture `result.id` as `ACCESS_APP_ID`. Do not hand-type it.

```sh
curl -sS -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/apps/$ACCESS_APP_ID/policies" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "content-type: application/json" \
  --data '{
    "name": "named administrators",
    "decision": "allow",
    "precedence": 1,
    "include": [
      {"email_domain": {"domain": "example.com"}}
    ]
  }'
```

The API response must say `success: true`. Follow it with a fresh GET of the exact application. A `201` proves creation, but the GET proves the stored hostname, policy and session settings are the ones you intended.

For infrastructure automation, create a service token separately:

```sh
curl -sS -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/service_tokens" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "content-type: application/json" \
  --data '{"name":"deploy smoke","duration":"720h"}'
```

The client secret is returned once. Store it in the deployment secret store immediately. The token still does nothing until a policy accepts it:

```sh
curl -sS -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/apps/$ACCESS_APP_ID/policies" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "content-type: application/json" \
  --data '{
    "name": "deployment machine",
    "decision": "non_identity",
    "precedence": 2,
    "include": [{"any_valid_service_token": {}}]
  }'
```

Cloudflare's API calls the `non_identity` decision **Service Auth** in the dashboard. That naming difference is worth writing in the runbook; otherwise an operator comparing JSON with the UI can think the wrong policy was created.

## A service token is two secret headers and one policy

The normal first request carries:

```sh
curl -sS https://admin.example.com/health \
  -H "CF-Access-Client-Id: $ACCESS_CLIENT_ID" \
  -H "CF-Access-Client-Secret: $ACCESS_CLIENT_SECRET"
```

Cloudflare checks the two values, evaluates a Service Auth policy, and forwards the request with `Cf-Access-Jwt-Assertion`. A successful request can also return a `CF_Authorization` cookie. If the application contains only Service Auth policies, Cloudflare requires the service token on subsequent requests too; the JWT cookie alone is not enough.

Access also supports a single custom header containing both values. That helps SaaS clients with one configurable authorization field. It does not help software with no custom-header extension point.

That limitation is common, not theoretical.

`kennypy` put Jellyfin behind Access. Google SSO worked in a browser, but the Findroid client could not add the two headers and became LAN-only. `hippiuS` hit the same shape with an MCP client calling ArgoCD: the request became a `302` to an SSO page a non-browser could not follow, or a `403`. The ArgoCD CLI needed a general `--header` flag before it could work with this class of proxy authentication.

The rule: **check the client's HTTP surface before choosing Access for the endpoint.** “It can call HTTPS” is insufficient. It must be able to set two headers, one configured compound header, mTLS credentials, or an Access-aware token.

## The service-token JWT has authority but may have no user

The recovered first-party probe created a temporary self-hosted application, added Service Auth, minted a temporary service token, and called a protected path. With valid headers, Access let the request reach the origin. The origin returned its own `404`, which is the useful proof: the credential cleared the edge policy.

The redacted application-token payload had this shape:

```json
{
  "type": "app",
  "iat": 1785041363,
  "exp": 1785043164,
  "iss": "https://<team-name>.cloudflareaccess.com",
  "sub": "",
  "aud": ["<application-audience>"],
  "common_name": "<service-token-client-id>"
}
```

There was no `email` claim. `sub` was the empty string.

`dataGriff` documented the consequence in a CI smoke test: once Access was enforced, the service-token caller had no user id to own a review and no human admin standing. The correct repair is not to invent an email inside every handler. Map the verified service principal once, at the authentication seam:

```js
function principalFromAccessClaims(claims) {
  if (claims.type === "app" && claims.common_name) {
    return {
      kind: "machine",
      id: `access-service:${claims.common_name}`,
      roles: ["deploy-smoke"],
    };
  }
  if (claims.email && claims.sub) {
    return {
      kind: "human",
      id: claims.sub,
      email: claims.email,
      roles: rolesForEmail(claims.email),
    };
  }
  throw new Error("Access token has no usable principal");
}
```

The application authorizes `deploy-smoke` to do only the smoke-test operations. It does not promote every service token to administrator. `common_name` is useful only after the JWT signature, issuer and audience have passed.

## Verify the JWT at the origin

Read `Cf-Access-Jwt-Assertion`. Cloudflare recommends that header because the cookie is not guaranteed to reach the origin. Then verify:

1. The signature against the team's JWKS.
2. `alg` is the expected RS256 algorithm.
3. `iss` equals the exact team-domain issuer.
4. `aud` contains the exact Access application audience tag.
5. `exp` and `nbf` permit the current time.
6. The resulting human or machine principal is authorized for this application action.

With `jose`:

```js
import { createRemoteJWKSet, jwtVerify } from "jose";

const TEAM_DOMAIN = process.env.ACCESS_TEAM_DOMAIN;
const ACCESS_AUD = process.env.ACCESS_AUD;
const issuer = `https://${TEAM_DOMAIN}`;
const jwks = createRemoteJWKSet(
  new URL(`${issuer}/cdn-cgi/access/certs`),
);

export async function requireAccess(request) {
  const token = request.headers.get("Cf-Access-Jwt-Assertion");
  if (!token) return { ok: false, status: 401, error: "missing Access JWT" };

  try {
    const { payload, protectedHeader } = await jwtVerify(token, jwks, {
      issuer,
      audience: ACCESS_AUD,
      algorithms: ["RS256"],
    });
    return {
      ok: true,
      claims: payload,
      algorithm: protectedHeader.alg,
      principal: principalFromAccessClaims(payload),
    };
  } catch {
    return { ok: false, status: 403, error: "invalid Access JWT" };
  }
}
```

Do not hard-code a PEM. The public endpoint carries the current signing key and the previous rotated key. The fresh read on this account returned two RSA signing keys, both RS256. A remote JWKS loader selects by `kid` and survives rotation.

## Bypass is a public route, not machine authentication

A Bypass policy removes Access from matching traffic. Cloudflare does not apply Access security controls to it, and the request is not present in Access logs. That can be correct for a public payment webhook whose provider cannot send Access credentials, provided the handler verifies the provider's own signature and rejects replay.

It is not a shortcut for a private API.

`lesbass` reported a split application where the unauthenticated health endpoint worked while every company-scoped API call failed with `RESPONSIBLE_USER_UNAVAILABLE`. The Access identity existed, but it did not map to a company member. Opening more paths would hide the identity defect by removing authentication from them.

Use the narrowest path possible. Put a separate handler-level signature on a bypassed webhook. Do not Bypass `/api/*` because one vendor callback needs to be public.

## Deleting one object proves nothing about the other two

An Access deployment usually creates at least three resources:

| Resource | What deleting it removes | What remains |
| --- | --- | --- |
| Access application | Hostname/path protection and attached application policies | Reusable policies and service tokens may remain |
| Application policy | One Allow, Block, Bypass or Service Auth decision | Application and credentials remain |
| Service token | That client id and secret | Application and Service Auth policy remain, ready to accept another valid token |

The proof sequence is explicit:

```sh
curl -sS -X DELETE \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/apps/$ACCESS_APP_ID" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"

curl -sS -X DELETE \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/service_tokens/$SERVICE_TOKEN_ID" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"

curl -sS \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/apps" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"

curl -sS \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/service_tokens" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
```

Require HTTP success and Cloudflare `success: true` before examining either list. A `401`, `403`, or empty parse is not absence proof. The final lists must contain neither the exact id nor the exact temporary name.

The temporary measurement used for this page was rechecked at `2026-07-26T06:09:10Z`. Both authenticated lists succeeded. Neither the application id/name nor the service-token id/name was present. Zero probe resources remained.

## When a single bearer key is the better answer

This application's owner surface does not use Access. Its middleware accepts one owner key by request header or URL parameter, or a signed 60-day HttpOnly admin-session cookie minted after the key is entered once. An unauthenticated browser is redirected to the public login page. An unauthenticated machine request receives a bounded `401` JSON object.

That posture accepts a sharp trade: one strong secret has no person-level identity, no IdP offboarding and no device-posture check. In return, any HTTP client that can set one header can use it, the application controls the exact failure response, and machine calls do not depend on an SSO redirect.

For one owner and a closed automation surface, that can beat Access. For 20 administrators who need individual revocation and audit attribution, it does not.

| Option | Best fit | Identity | Machine-client requirement | Verdict |
| --- | --- | --- | --- | --- |
| Cloudflare Access | Several people, existing IdP, per-person revocation | Human email/groups; machine principal for service tokens | Custom headers, mTLS, or Access-aware client | Default for a shared admin UI |
| mTLS | Services or managed devices with certificate lifecycle | Certificate subject or mapped device | Client-certificate support | Strong machine auth; heavier issuance and rotation |
| One bearer key checked in the Worker | One owner, small fixed set of scripts | Shared principal only | One configurable header | Best simple answer when per-person identity adds no value |
| IP allow-list | Fixed corporate egress as one factor | Network location, not a person | Stable source IP | Use as a condition, not the only credential |
| Tunnel plus Access | Private origin that must not be directly reachable | Access identity plus private origin path | Browser login or service credential | Strongest Access topology for a self-hosted origin |
| Bypass plus handler signature | One third-party webhook | Provider key/signature | Provider-specific signed request | Correct for that path only |

## Seats and arithmetic

Cloudflare's current plan page says the Free plan is for teams under 50 users and costs $0. Pay-as-you-go is $7 per user per month. The page describes Remote Browser Isolation as an add-on but does not publish its current add-on price. A dated 2023 operator comparison recorded $10 per user per month; treat that as historical evidence, not today's quote.

| Administrators | Access Free | Pay-as-you-go at $7/seat/month | Shared bearer key |
| ---: | ---: | ---: | ---: |
| 1 | $0 | $7 | $0 product fee |
| 12 | $0 | $84 | $0 product fee |
| 49 | $0 | $343 | $0 product fee |
| 60 | Plan choice required; outside “under 50” positioning | $420 | $0 product fee, but 60 people sharing one key is indefensible |
| 250 | Not the free-plan fit | $1,750 | Wrong architecture |

The calculation is seats × $7. It excludes support, identity-provider cost, implementation time, and any separately quoted Remote Browser Isolation add-on. A bearer key has no Cloudflare seat line item, but secret rotation and the absence of individual attribution are costs; they are just paid in operator time and incident risk.

## What administering Access feels like

The policy surface is capable. The console has drawn specific criticism. `systemvoltage`, otherwise positive about Cloudflare's main dashboard, described the Access/Zero Trust area as a separate application that took ten seconds and redirected repeatedly, with worse UI and thin documentation.

That report is dated 2022. Do not turn it into a claim about today's page speed. Keep the durable operational lesson: the person on call needs the API paths and curl proofs in the runbook, because a graphical console can be slow, moved, or unavailable.

The positive operator case is equally concrete. `tbhb` uses Tunnel plus Access to expose only the local-development endpoints that must be public, such as webhooks, while keeping the rest of the site behind Access. That is the product boundary working: narrow public ingress, authenticated private remainder, and no directly published origin.

## Error, cause, repair

| Symptom | Cause | Repair |
| --- | --- | --- |
| `302` to `*.cloudflareaccess.com/cdn-cgi/access/login/...` | No accepted credential and the application is using interactive login behavior | Browser: complete the IdP flow. Machine: send a service token and add Service Auth, or enable the documented 401 response for Service Auth |
| `403` before the origin | Invalid service headers, no matching policy, wrong application path, or denied selector | Confirm both header names, list the application and policies, then test the exact hostname/path |
| Valid service token reaches origin but `sub` is empty | Service-token application JWT is non-human | Map verified `common_name` to a least-privilege synthetic machine identity |
| JWT signature verification fails | Wrong issuer, wrong audience, stale hard-coded key, altered token, or wrong algorithm | Fetch the team JWKS, select by `kid`, require RS256, exact issuer and exact application audience |
| Browser works; native client gets 302/403 | Client cannot add Access service-token headers | Add a general custom-header option, use the single-header mode, mTLS, or do not put that endpoint behind Access |
| Health works; every scoped API call fails | Health is public/Bypass while authenticated principal is not mapped into app membership | Fix identity mapping at the auth seam; do not widen Bypass |
| Origin accepts a claimed Access header without cryptographic verification | Application trusts attacker-supplied text | Verify JWT signature, issuer, audience and time before reading identity |
| Temporary app appears deleted but token remains | Only the Access application was deleted | Delete the service token separately; require fresh successful lists for both collections |

## Three live receipts, with bounded claims

**Access path.** A temporary application protected a unique path. Plain HTTP returned `302` before Service Auth and `403` after the Service Auth policy existed. Correct service-token headers passed Access and reached the origin, which returned its own `404`. The redacted JWT used RS256, had `type: "app"`, an audience and `common_name`, an empty `sub`, and no `email`.

**Signing keys.** A fresh unauthenticated GET of the account's team JWKS returned HTTP `200`, 4,914 JSON bytes and two RSA/RS256 signing keys. The published command uses a placeholder, not the real team name:

```sh
curl -sS "https://<team-name>.cloudflareaccess.com/cdn-cgi/access/certs" \
  | jq '{keys: [.keys[] | {kid, alg, kty, use}]}'
```

**This application's key-only admin gate.** A fresh machine request with no credential:

```sh
curl -sS -D - https://miscsubjects.com/admin \
  -H 'accept: application/json'
```

returned HTTP `401`, `application/json`, and a 47-byte body with only `error` and `login`. A scan found no stack, trace, binding, database, exception or key marker. This proves the unauthenticated failure is bounded; it does not prove the shared-key posture has person-level identity.

**Cleanup.** Successful authenticated Access application and service-token lists proved the temporary ids and names absent. No `401`, `403`, or failed parse was treated as an empty list.

Access earns its complexity when identity changes the authorization decision. If every accepted caller is the same owner and every client already holds the same operational secret, one checked key is smaller and often more reliable. Once individual revocation, IdP groups or device posture matter, use Access, verify the JWT at the origin, and give machines a principal of their own.

This chapter is part of [the Cloudflare account inventory](/a/cloudflare-os). For the private-origin and Durable Object boundary, see [Workers and Durable Objects](/a/cloudflare-os-workers).


## Sources

1. Authorization cookie — https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/
2. Application token — https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/application-token/
3. Validate JWTs — https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/
4. Access policies — https://developers.cloudflare.com/cloudflare-one/access-controls/policies/
5. Service tokens — https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/
6. Add a self-hosted application — https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app/
7. Create an Access application — https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/applications/methods/create/
8. Create a service token — https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/service_tokens/methods/create/
9. Zero Trust services plans — https://www.cloudflare.com/plans/zero-trust-services/
10. Remote Browser Isolation — https://developers.cloudflare.com/cloudflare-one/remote-browser-isolation/
11. Cloudflare Tunnel — https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
12. panva/jose — https://github.com/panva/jose
13. Add support for Cloudflare Access Service Tokens (Custom Headers) — https://github.com/jarnedemeulemeester/findroid/issues/1016
14. Support custom HTTP headers on outbound ArgoCD API requests — https://github.com/argoproj-labs/mcp-for-argocd/issues/115
15. Give Access service-token callers a usable identity — https://github.com/dataGriff/outcome-app-pattern-whiskey/issues/4
16. Cloudflare Access blocker prevents Paperclip API operations — https://github.com/lesbass/ai-newsroom/issues/4
17. Comment on Cloudflare's Access console — https://news.ycombinator.com/item?id=31332325
18. Comment on Browser Isolation pricing — https://news.ycombinator.com/item?id=35494875
19. Comment on Tunnel plus Access — https://news.ycombinator.com/item?id=41915668
20. Temporary Access request-path receipt — https://miscsubjects.com/api/articles/cloudflare-os-access
21. Service application-token receipt — https://miscsubjects.com/api/articles/cloudflare-os-access
22. Fresh JWKS and owner-gate receipt — https://miscsubjects.com/api/articles/cloudflare-os-access
23. Temporary resource deletion proof — https://miscsubjects.com/api/articles/cloudflare-os-access


---

# Cloudflare email is three products, not one mail stack

slug: cloudflare-os-email · https://miscsubjects.com/a/cloudflare-os-email · tags: cloudflare, architecture, email, cloudflare-os · updated 2026-07-26T06:16:48.370Z

# Cloudflare email is three products, not one mail stack

Cloudflare can forward inbound mail, run code on it, and send transactional mail. Each capability has a different setup gate. A verified forwarding address is not an onboarded sending domain.

| Product surface | Direction | What it does | Prerequisite | What it does not replace |
| --- | --- | --- | --- | --- |
| Email Routing | Inbound | Maps an address or catch-all to a verified destination or Worker | Domain onboarded for routing; routing MX, SPF, and DKIM records | A mailbox, outbound sender, campaign system |
| Email Workers | Inbound, plus constrained reply/forward | Runs an `email()` handler over the raw message | Active route bound to a deployed Worker; destinations verified before forwarding | General arbitrary outbound on the free plan |
| Email Service / Email Sending | Outbound | Sends transactional mail through a Worker binding, REST, or SMTP | Sending domain onboarded; Paid plan for arbitrary recipients; `send_email` binding for Workers | Marketing automation, customer subaccounts, full ESP operations |

Verified destinations are free on every plan; arbitrary recipients require Workers Paid. Paid includes 3,000 outbound messages per account each month, then costs $0.35 per 1,000. Inbound is unlimited, although processing consumes Worker resources.

[[embed:source:s1]]

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## The setup begins with DNS, not code

For inbound routing, the dashboard path is:

`Cloudflare dashboard → account → Compute → Email Service → Email Routing → Onboard Domain`

For `example.com`, Cloudflare creates this root-domain shape:

```txt
MX  @  route1.mx.cloudflare.net
MX  @  route2.mx.cloudflare.net
MX  @  route3.mx.cloudflare.net
TXT @  "v=spf1 include:_spf.mx.cloudflare.net ~all"
TXT cf2024-1._domainkey  "v=DKIM1; h=sha256; k=rsa; p=<Cloudflare public key>"
```

Cloudflare assigns MX priorities. Merge the Cloudflare `include:` into an existing SPF record; two SPF records are invalid, and SPF has a ten-lookup ceiling.

[[embed:source:s2]]

For outbound Email Sending, onboarding is separate:

`Cloudflare dashboard → account → Compute → Email Service → Email Sending → Onboard Domain`

The outbound records live under `cf-bounce.example.com`, leaving the inbound root MX records alone:

```txt
MX  cf-bounce  route1.mx.cloudflare.net
MX  cf-bounce  route2.mx.cloudflare.net
MX  cf-bounce  route3.mx.cloudflare.net
TXT cf-bounce  "v=spf1 include:_spf.mx.cloudflare.net ~all"
TXT cf-bounce._domainkey  "v=DKIM1; h=sha256; k=rsa; p=<Cloudflare public key>"
TXT _dmarc  "v=DMARC1; p=none; rua=mailto:dmarc@example.com"
```

Cloudflare says DNS commonly settles in 5–15 minutes but can take up to 24 hours. The practical gate is that the Email Sending screen shows the domain onboarded and DNS queries return the records. Start DMARC at `p=none` if other providers still send for the domain; enforce only after their identities align.

[[embed:source:s3]]

## Routing rules, verified destinations, and the catch-all

Forwarding requires a destination address that the recipient has verified. The dashboard path is:

`Compute → Email Service → Email Routing → Destination Addresses`

Cloudflare emails that address a verification link. A routing rule pointing at an unverified destination remains disabled. Once verified, create a rule under:

`Compute → Email Service → Email Routing → Routing Rules → Create routing rule`

The action is send to a verified address, send to a Worker, or drop. If two rules use the same pattern, only the first processes the message. Renaming a Worker breaks routes that point to its old name.

[[embed:source:s4]]

The catch-all is a separate rule on the Routing Rules screen. Turn it on, choose **Send to a Worker**, select the deployed Worker, and save. It catches every otherwise-unmatched local part, so add size checks, sender policy, and retention.

| Intent | Route | Destination |
| --- | --- | --- |
| Ordinary inbox alias | `hello@example.com` | Verified personal or team inbox |
| Support ingestion | `support@example.com` | Email Worker |
| Per-customer intake | `inbox+customer-id@example.com` | Email Worker with subaddressing enabled |
| Any unmatched local part | Catch-all | Worker, then explicit allow/reject logic |
| Address that should appear valid but retain nothing | Named rule | Drop |

With subaddressing enabled, `inbox+acme@example.com` matches `inbox@example.com` while preserving `+acme` in `message.to`.

## A runnable inbound Worker that parses MIME and stores attachments

The useful inbound architecture is short:

`Cloudflare MX → Email Routing catch-all → email() handler → postal-mime → R2 objects + application record`

Install `postal-mime`, bind an R2 bucket, and deploy the Worker:

```bash
npm install postal-mime
npx wrangler r2 bucket create inbound-attachments
npx wrangler deploy
```

```jsonc
{
  "name": "inbound-mail",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-01",
  "r2_buckets": [
    { "binding": "ATTACHMENTS", "bucket_name": "inbound-attachments" }
  ]
}
```

```ts
import PostalMime from "postal-mime";

interface Env {
  ATTACHMENTS: R2Bucket;
  FORWARD_TO: string;
}

function safeName(name: string): string {
  return name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 180);
}

export default {
  async email(message: ForwardableEmailMessage, env: Env): Promise<void> {
    if (message.rawSize > 25 * 1024 * 1024) {
      message.setReject("Message exceeds the 25 MiB inbound limit");
      return;
    }

    const parsed = await PostalMime.parse(message.raw);
    const received = new Date().toISOString();
    const mailId = crypto.randomUUID();

    for (const [index, attachment] of (parsed.attachments || []).entries()) {
      const filename = safeName(attachment.filename || `attachment-${index}`);
      const key = `mail/${received.slice(0, 10)}/${mailId}/${filename}`;

      await env.ATTACHMENTS.put(key, attachment.content, {
        httpMetadata: {
          contentType: attachment.mimeType || "application/octet-stream"
        },
        customMetadata: {
          envelopeFrom: message.from,
          envelopeTo: message.to,
          subject: (parsed.subject || "").slice(0, 500)
        }
      });
    }

    await message.forward(env.FORWARD_TO);
  }
} satisfies ExportedHandler<Env>;
```

`message.raw` is a single stream. If two consumers need it, buffer once with `new Response(message.raw).arrayBuffer()`. The handler also exposes envelope addresses, headers, size, `setReject()`, `forward()`, and `reply()`.

[[embed:source:s5]]

`postal-mime` handles multipart boundaries, encodings, and character sets. Treat filenames and MIME types as untrusted. Generate the key, cap size, and scan before serving.

[[embed:source:s6]]

One operator reports: “I’m using Cloudflare Email Routing with a catch-all address that triggers a Worker. The Worker parses the email and stores the attachments in R2.” It is one working implementation, not a universal guarantee.

[[embed:source:s7]]

For the storage half, see [R2 cuts a 10 TB delivery bill from $923 to $18.45](/a/cloudflare-os-r2). That chapter covers binding calls, public versus private objects, versioning gaps, and cost.

## Replying is not the same as arbitrary sending

The inbound message object can forward to verified routing destinations. It can also reply with a raw `EmailMessage` constructed from `cloudflare:email`:

```ts
import { EmailMessage } from "cloudflare:email";
import { createMimeMessage } from "mimetext";

export default {
  async email(message: ForwardableEmailMessage): Promise<void> {
    const msg = createMimeMessage();
    msg.setSender({ name: "Example support", addr: "support@example.com" });
    msg.setRecipient(message.from);
    msg.setSubject("Re: " + (message.headers.get("subject") || "your message"));
    msg.addMessage({
      contentType: "text/plain",
      data: "We received your message."
    });

    const reply = new EmailMessage(
      "support@example.com",
      message.from,
      msg.asRaw()
    );
    await message.reply(reply);
  }
};
```

This is tied to the inbound message. `message.reply()` throws above 100 `References` entries, and forwarding destinations must be verified.

[[embed:source:s8]]

## New outbound mail uses Email Service

After onboarding, bind Email Service to a paid Worker:

```jsonc
{
  "send_email": [
    { "name": "EMAIL" }
  ]
}
```

Call `env.EMAIL.send()` with an onboarded-domain `from`, recipient objects, subject, text, and HTML.

The prerequisite chain is:

1. The zone is in the Cloudflare account.
2. Email Sending shows the domain as onboarded.
3. The `cf-bounce` MX, SPF, DKIM, and DMARC records resolve.
4. The Worker is on Workers Paid for arbitrary recipients.
5. The Worker has a `send_email` binding and uses the onboarded domain in `from`.
6. The message stays inside recipient, header, and size limits.

Before onboarding, sends are limited to verified destinations and routing domains. After onboarding, arbitrary recipients are allowed, subject to daily quota and reputation. New accounts start with a conservative quota that Cloudflare adjusts over time rather than publishing one universal number.

[[embed:source:s9]]

Current limits include 50 combined recipients, 998 subject characters, 16 KB of custom headers, and 5 MiB total. Verified-destination mail may be 25 MiB. A zone may have 30 combined Routing and Sending domains; Routing allows 200 rules per domain and 200 verified destinations per account.

## The site's own split proves why the prerequisite must be visible

This build has a Pages endpoint at `POST /api/email/send`. It authenticates the owner, then proxies the body to a sibling Worker because Pages cannot carry the `send_email` binding. The sibling declares:

```toml
[[send_email]]
name = "EMAIL"
```

The public diagnostic response currently says:

```json
{
  "inbound": {
    "loop@miscsubjects.com": "forward → owner@redacted",
    "build@miscsubjects.com": "worker → ledger + forward"
  },
  "sending": "Enable Email Sending on miscsubjects.com in CF dashboard (Pages cannot bind send_email)"
}
```

Code and a binding are not proof of a send-capable domain. The prerequisite remains open until onboarding and an observed delivery receipt.

[[embed:source:s10]]

On July 26, 2026, `miscsubjects.com` resolved three Cloudflare routing MX records, root SPF, `cf2024-1` DKIM, and DMARC `p=reject`. The sending selector also returned a key, but DNS alone does not prove onboarding or delivery. No email was sent.

[[embed:source:s11]]

## SPF, DKIM, DMARC, and ARC in plain language

| Mechanism | What it asserts | What forwarding changes |
| --- | --- | --- |
| SPF | The connecting server is authorized for the envelope sender's domain | A forwarder connects from a new IP, so the original SPF relationship can break |
| DKIM | A domain signed selected headers and body bytes with a key published in DNS | It can survive forwarding if the signed bytes remain intact |
| DMARC | The visible `From:` domain must align with a passing SPF or DKIM identity, then applies a policy | Forwarding can disturb SPF; mailing-list or gateway modifications can disturb DKIM |
| ARC | Intermediaries preserve a signed chain of the authentication result they observed | A destination can evaluate the forwarder's attestation when direct SPF or DKIM no longer tells the whole story |

Cloudflare uses Sender Rewriting Scheme, changing the envelope sender so SPF can pass from its relay while leaving visible `From:` unchanged. Routing adds DKIM, and ARC preserves authentication results through the forwarding hop.

[[embed:source:s12]]

ARC is evidence, not an override switch. The final provider still applies its own reputation, block-list, policy, and content decisions. A message can authenticate and still be rejected or placed in spam.

## The Outlook reports are real, but they are not a universal result

Two independent operator reports describe Microsoft blocking Cloudflare Email Routing IP ranges. Handy-Man writes that Outlook “just blocks Cloudflare IP ranges and emails never get routed to my Outlook mail box.”

[[embed:source:s13]]

In a separate discussion, doubled112 reports that Microsoft “intermittently block Cloudflare email routing IPs too,” despite the surrounding SPF, DKIM, and DMARC work.

[[embed:source:s14]]

A third independent account documents Outlook failures through Cloudflare routing.

[[embed:source:s15]]

These reports establish a failure mode, not prevalence. Shared relays can inherit reputation from other traffic. Authentication improves identity evidence; it does not require a provider to accept an IP.

When forwarded mail disappears, inspect the Email Routing activity log first. Separate these cases:

| Symptom | Likely cause | Check | Fix |
| --- | --- | --- | --- |
| Rule is disabled | Destination never verified | Destination Addresses status | Resend verification and activate the rule |
| No routing event | MX or rule mismatch | `dig MX`, rule order, exact recipient | Finish onboarding; repair the pattern |
| Worker invocation failed | CPU, memory, exception, or renamed Worker | Workers logs; route target | Fix the exception or rebind the renamed Worker |
| Forward call rejects | Destination is not verified | Destination list and Worker log | Verify the exact address before forwarding |
| Routing says delivered, inbox has nothing | Destination provider rejected, deferred, or filtered it | Routing activity, SMTP response, spam/quarantine | Test another verified destination; give Cloudflare the event and SMTP evidence |
| Sending call succeeds but Routing summary says “dropped” | Outbound Worker mail is shown that way in Routing | Email Sending metrics and logs | Use Email Sending observability, not the Routing label |
| Local attachment test throws `Cannot serialize value: [object ArrayBuffer]` | Local runtime limitation | Reproduce on deployed Worker | Test binary attachments against the deployed Worker |
| Reply throws on a long thread | More than 100 References entries | Count `References` values | Start a new message or trim the reply path |

Cloudflare warns about multiple SPF records, missing selectors, alignment, new-domain reputation, bounces, and provider filtering. Binary attachments can also hit a local ArrayBuffer serialization limit even when deployment works.

[[embed:source:s16]]

One developer followed the `cloudflare:email` and `EmailMessage` path, verified an address, and reported no arrival or error. The unanswered report cannot establish cause. It does show why a resolved Promise is not delivery proof. Check destination verification, onboarding, Email Sending logs, spam, quarantine, and SMTP events.

[[embed:source:s17]]

## Where Cloudflare Email Service still is not SendGrid

Transactional sending is not the whole ESP product.

One SendGrid operator creates subaccounts by API so customers can verify their own domains with DKIM and SPF. Cloudflare's documented flow onboards zones from its account; it does not document an equivalent delegated subaccount system. Do not promise one without a specific API and proof.

[[embed:source:s18]]

The gaps are operational: durable bounce handling, suppression recovery, versioned templates, tenant analytics, and marketing consent or unsubscribe controls. Conservative daily quotas, reputation scaling, 50 recipients per message, and the limit-increase process keep “transactional” a real boundary.

A project can build those layers on Workers, D1, R2, and Queues. One operator is building an AGPL email platform in each user's Cloudflare account. Once those layers are included, the work is an email product, not a send call.

[[embed:source:s19]]

## Cost is attractive; replacement scope is the constraint

At current list price, Cloudflare Paid includes 3,000 outbound messages monthly and charges $0.35 per 1,000 after that.

| Outbound transactional volume | Cloudflare Email Service usage line | What the arithmetic excludes |
| --- | --- | --- |
| 3,000/month | Included in Workers Paid | Paid plan itself; application work |
| 10,000/month | $2.45 above the included 3,000 | Templates, bounce workflow, analytics |
| 100,000/month | $33.95 above the included 3,000 | Reputation operations and support |
| 1,000,000/month | $348.95 above the included 3,000 | Quota approval and product controls |

The formula is `max(0, messages - 3,000) / 1,000 × $0.35`. Accepted hard bounces count; API-boundary rejections do not. Verified-destination sends are free.

Resend packages volume with retention, domain, team, and feature limits. Compare the operating requirement, not only cost per thousand.

[[embed:source:s20]]

Cloudflare's price works best for receipts, password resets, alerts, and other application mail from one owned domain. Customer-domain onboarding, mature suppressions, templates, analytics, and marketing change the comparison.

## The decision table

| Workload | Best first choice | Why |
| --- | --- | --- |
| Receive-only aliases into an existing inbox | Email Routing | No mailbox migration; verified forwarding destination |
| Receive and inspect, reject, archive, or branch | Email Routing → Email Worker | Code runs at the SMTP ingress and can store to R2 |
| Inbound email webhook with attachments | Catch-all or named route → Worker → parser → private R2 | One event path; binary payload leaves D1 |
| Transactional mail from one owned domain | Email Service on Workers Paid | Onboarded domain, low unit price, native binding |
| Send only to a few fixed internal addresses | Verified destinations | Free, constrained anti-abuse path |
| Customer-owned sending domains and subaccounts | Established ESP until proven otherwise | Tenant onboarding and reputation boundaries are product features |
| Marketing campaigns and newsletters | Marketing ESP | Consent, unsubscribe, segmentation, templates, analytics |
| Full hosted mailbox with IMAP, folders, search, calendars | Mail provider | Cloudflare Email Service is transport and compute, not a mailbox |

Migrate in the same order: verify inbound routing and destination receipt, then the Worker and stored R2 objects. Onboard sending separately and keep the ESP until bounces, suppressions, quotas, logs, and delivery have owners.

The final test is one real message in, the intended Worker invocation, the expected object or forward, and the result at the destination. Outbound needs the production send plus recipient delivery. Until then, it is configured, not proven.


## Sources

1. Email Service pricing — https://developers.cloudflare.com/email-service/platform/pricing/
2. Email Service troubleshooting — https://developers.cloudflare.com/email-service/reference/troubleshooting/
3. Email Service domain configuration — https://developers.cloudflare.com/email-service/configuration/domains/
4. Email routing rules and addresses — https://developers.cloudflare.com/email-service/configuration/email-routing-addresses/
5. Email handler Workers API — https://developers.cloudflare.com/email-service/api/route-emails/email-handler/
6. postal-mime source repository — https://github.com/postalsys/postal-mime
7. Show HN: Webhook API – inbound email –> webhook — https://news.ycombinator.com/item?id=47932438
8. Email Service limits — https://developers.cloudflare.com/email-service/platform/limits/
9. Email Service quotas and onboarding limits — https://developers.cloudflare.com/email-service/platform/limits/
10. Live miscsubjects email endpoint — https://miscsubjects.com/api/email/send
11. Live DNS measurement — https://miscsubjects.com/a/cloudflare-os-email
12. Email Service postmaster reference — https://developers.cloudflare.com/email-service/reference/postmaster/
13. Cloudflare Email Service: private beta — https://news.ycombinator.com/item?id=45373715
14. DKIM2 and DMARCbis Have Landed — https://news.ycombinator.com/item?id=48837913
15. When things start to fail: Cloudflare Email Routing — https://dariusz.wieckiewicz.org/en/when-things-start-to-fail-cloudflare-email-routing/
16. Cloudflare Email Service troubleshooting — https://developers.cloudflare.com/email-service/reference/troubleshooting/
17. Cannot send emails from Cloudflare worker — https://stackoverflow.com/questions/79733052/cannot-send-emails-from-cloudflare-worker
18. Cloudflare Email Service: private beta — https://news.ycombinator.com/item?id=45376469
19. Building an email platform on Workers + D1 + R2 + Queues — would like architecture feedback — https://old.reddit.com/r/CloudFlare/comments/1u90bev/building_an_email_platform_on_workers_d1_r2/
20. Resend pricing — https://resend.com/pricing


---

# Browser Rendering is an evidence adapter, not a better fetch()

slug: cloudflare-os-browser · https://miscsubjects.com/a/cloudflare-os-browser · tags: cloudflare, cloudflare-os, browser-run, browser-rendering, puppeteer, web-scraping, evidence, receipts, security · updated 2026-07-26T03:59:42.985Z

# Browser Rendering is an evidence adapter, not a better `fetch()`

A plain HTTP client retrieves bytes. Cloudflare Browser Run can execute the page, wait for its state to settle, and return a representation chosen for the next operation: rendered HTML, Markdown, selected elements, links, a screenshot, a PDF, an accessibility tree, structured JSON, or an asynchronous crawl.

That distinction is the whole chapter. A browser belongs in this system only where the evidence depends on browser execution or a browser-specific representation. It should not become the default transport. Making it the default spends more time and money, enlarges the security boundary, and can still return a convincing but incomplete page.

The capability catalogue therefore does not contain one vague `BROWSER` tool. It contains explicit contracts: what representation is requested, what completion condition is required, what authority may leave the system, and what receipt must come back. The browser is the eyes. The canonical catalogue decides when those eyes may open and what counts as seeing.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## The endpoint is a choice about evidence

Cloudflare exposes ten Quick Actions in the current documentation, including the beta crawl action. They overlap at the input—usually a URL—but not at the output. Choosing by convenience rather than by evidence type is how a screenshot gets mistaken for data, a Markdown conversion gets mistaken for the DOM, or a link inventory gets reconstructed expensively from a general browser session.

| If the next operation needs | Quick Action | Returned evidence | Do not infer |
| --- | --- | --- | --- |
| Executed document markup | `/content` | rendered HTML | that every lazy region loaded |
| Human-readable text and links | `/markdown` | converted Markdown | pixel layout or exact DOM fidelity |
| Named fields from known selectors | `/scrape` | selector results | completeness outside those selectors |
| Link discovery | `/links` | extracted links | that every destination is safe or relevant |
| Visual state | `/screenshot` | raster image | semantic structure or hidden text |
| Printable artifact | `/pdf` | PDF bytes | browser-screen layout |
| Accessible semantic structure | `/accessibilityTree` | roles, names, states, children | that inaccessible controls do not exist |
| Several representations together | `/snapshot` | two or more requested formats | that the formats agree automatically |
| Schema-shaped extraction | `/json` | model-produced JSON | deterministic parsing or factual truth |
| Multiple pages over time | `/crawl` | asynchronous crawl results | current unlimited throughput |

`/snapshot` is especially useful for evidence work because one browser state can yield a visual surface and structural surfaces together. Cloudflare says the action defaults to HTML plus screenshot and can add Markdown and the accessibility tree. That is not just fewer requests. It reduces the chance that two captures were made from different page states. The receipt should still record each format separately and hash the bytes separately, because a screenshot and HTML prove different things.

The inverse rule matters too. If a stable endpoint already returns JSON, call it with ordinary HTTP. If static HTML contains the needed text, use ordinary HTTP. If all that is required is a status code or header, a browser weakens the measurement by adding navigation, rendering and conversion work that the question never asked for.

## A browser can execute a page without proving the page is complete

JavaScript execution is necessary for many modern pages, but it is not a completion oracle. Single-page applications often paint an initial shell, issue more requests, then reveal content after a selector appears. Cloudflare's Quick Action documentation repeatedly warns that the default result may be incomplete for SPAs and points to `waitForSelector` or navigation wait options.

That means every browser capability needs an explicit completion contract. “Open this URL” is not one.

| Completion contract | Good for | Failure it prevents |
| --- | --- | --- |
| `waitUntil: "domcontentloaded"` | server-rendered page with small client enhancement | waiting for irrelevant long-lived connections |
| `waitUntil: "networkidle0"` | bounded application that becomes quiet | capturing before dependent requests finish |
| `waitForSelector: "#results"` | a known state transition | treating the application shell as the result |
| fixed delay | almost nothing by itself | none; it only moves the race |
| application assertion | login, checkout, dashboard state | proving the wrong authenticated or error state |

A useful row therefore separates navigation from success:

```json
{
  "key": "BROWSER_MARKDOWN",
  "what": "Return Markdown after the named page state exists.",
  "args": {
    "url": "https URL",
    "wait_for_selector": "optional CSS selector",
    "timeout_ms": "bounded integer"
  },
  "authority": {
    "hosts": ["developers.cloudflare.com"],
    "cookies": false,
    "custom_headers": []
  },
  "receipt": {
    "final_url": true,
    "status": true,
    "browser_ms": true,
    "body_sha256": true,
    "selector_observed": true
  }
}
```

The row is discoverable because its `what` names Markdown and page state. It is invokable because the arguments are concrete. It is auditable because the allowed hosts and credential channels are visible. It is replayable because the receipt records the final URL, completion observation and content hash. The same row can project to REST documentation, a model tool schema, a CLI command and an admin form without inventing four contracts.

## The first-party receipt: 208 browser milliseconds, not a speed claim

On 26 July 2026 this build called the production `/browser-rendering/markdown` REST action against `https://example.com`. The token was read from the local credential store and was never copied into the artifact. The response was successful and contained the expected “Example Domain” heading.

| Fresh measurement | Result |
| --- | ---: |
| Cloudflare response status | 200 |
| Client-observed elapsed time | 1,418 ms |
| `X-Browser-Ms-Used` | 207.706 ms |
| API response bytes | 199 |
| Returned Markdown characters | 167 |
| Expected heading present | yes |

This is a receipt for one request from one client to one stable target. It is not a latency benchmark, an availability claim, or evidence that arbitrary protected sites will render. Client elapsed time includes network and API overhead. The browser-time header measures billable browser work for that Quick Action, not total wall time.

The reproduction is deliberately small:

```bash
curl -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/browser-rendering/markdown" \
  -H "Authorization: Bearer $BROWSER_RENDERING_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"url":"https://example.com"}'
```

The portable version should read the account identifier and token from environment or a secret store, never from a catalogue row, prompt, receipt or shell history. Record the response status, final representation hash and `X-Browser-Ms-Used`; discard the bearer token before ledgering.

## The bill is browser time, and sessions add a second meter

Cloudflare distinguishes Quick Actions from Browser Sessions. Quick Actions are charged for browser hours. Direct sessions through Puppeteer, Playwright or CDP are charged for browser hours and, on paid plans above the included allowance, concurrent browsers.

The current published table gives Workers Free ten browser minutes per day. Workers Paid includes ten browser hours per month and then charges $0.09 for each additional browser hour. Browser Sessions include three concurrent browsers on Free; Paid includes ten averaged monthly and then lists $2 for each additional concurrent browser. The Quick Action response header reports browser milliseconds used, which is the useful per-invocation receipt field.

| Cost or limit surface | Workers Free | Workers Paid default |
| --- | ---: | ---: |
| Browser time | 10 minutes/day | 10 hours/month, then $0.09/hour |
| Quick Action rate | 1 request/10 seconds | 10 requests/second |
| Session browsers | 3 concurrent | 120 concurrent limit |
| Included session concurrency for pricing | 3 | 10 monthly-average daily peak |
| New session instances | 1 every 20 seconds | 1/second |
| Inactivity timeout | 60 seconds | 60 seconds |
| Configurable inactivity timeout | up to 10 minutes | up to 10 minutes |

The 120-browser paid limit and the ten-browser paid price inclusion answer different questions. Conflating them makes a cost table wrong. So does multiplying the 208 ms receipt by the $0.09 rate and presenting the fraction of a cent as an invoice: Cloudflare aggregates daily seconds and rounds the monthly browser-hour total. The individual header supports attribution and anomaly detection; billing still follows the aggregate rules.

Direct sessions need stricter lifecycle code:

```js
let browser;
try {
  browser = await puppeteer.launch(env.BROWSER);
  const page = await browser.newPage();
  await page.goto(target, { waitUntil: "networkidle0" });
  return await page.content();
} finally {
  if (browser) await browser.close();
}
```

Cloudflare warns that a session left open continues consuming browser time until the inactivity timeout. An issue in `cloudflare/workers-sdk` also reported `browser.close()` hanging under local Vite and Wrangler development while production worked. That report is one historical local-development reproduction, not evidence that current production close calls hang. It is enough to justify a bounded close operation, a recorded close reason and a test of local and deployed paths separately.

## “Cloud browser” does not mean “bypass”

Changing the User-Agent does not turn Browser Run into an unidentifiable residential client. Cloudflare states that Browser Run requests are always identified as bot traffic and that a custom User-Agent does not bypass bot protection. A remote Chrome may execute client JavaScript that plain fetch cannot, yet the destination can still challenge or refuse it.

This has two consequences.

First, the browser capability must report refusal as refusal. A rendered challenge page with status 200 is not the requested article. Success needs a content assertion: selector observed, expected heading present, schema satisfied, or another target-specific check.

Second, the system must not market Browser Run as a way around a publisher's controls. Robots rules, authorization, terms, rate limits and data handling remain part of the invocation policy. Browser execution changes the client. It does not confer permission.

One Hacker News commenter said they moved to remote browser rendering because bot protection made direct fetching unworkable. Another asserted that Perplexity was using Cloudflare Browser Rendering for scraping. Those are observations from named operators, not universal proof of bypass, permission, scale, reliability or present product behavior. They establish that practitioners reach for this category of tool in the exact gap between plain HTTP and executed pages. They do not settle whether any particular target should be fetched.

## The output can be wrong even when the browser worked

Transport success and representation correctness are separate gates. A GitHub report against `/crawl` showed root-relative image paths being resolved as page-relative paths in converted Markdown, producing broken image URLs while the HTML output remained correct. That is an externally reported converter defect on particular pages, not proof that all current Markdown is broken. It demonstrates why the receipt should retain the source URL, format, converter version when available, and a second representation for material captures.

For critical evidence:

1. capture rendered HTML plus the representation used downstream;
2. retain the final URL after redirects;
3. hash both outputs;
4. validate required links or fields against the HTML;
5. label model-extracted JSON as derived;
6. store a screenshot when the claim is visual;
7. fail closed when a required selector or assertion is absent.

The `/json` action deserves an extra warning. Cloudflare documents it as AI-assisted extraction and says the default model is Workers AI's Llama 3.3 70B FP8 Fast unless another provider is supplied. A schema can constrain shape. It cannot make the content deterministic or true. JSON produced by a model is derived evidence and should preserve the prompt, schema, model identity, input hash and validation result. It should never overwrite the rendered source.

| Evidence status | Browser example | What can be claimed |
| --- | --- | --- |
| observed | screenshot visibly contains an error banner | the banner was visible in that capture |
| derived | model maps rendered page into a product schema | the model produced fields from that input |
| specified | Cloudflare documents a request limit | the published contract states the limit |
| implemented | catalogue row and adapter exist in code | this version contains the path |
| deployed | production endpoint accepts the row | the deployed version exposes it |
| reproduced | controlled call returns the expected representation | the tested input worked at that time |
| externally attested | named operator reports a failure or use | that operator reported that experience |

## Crawl is a queue, not a big page request

The beta `/crawl` action is asynchronous. A POST creates a job; subsequent reads retrieve status and results. Cloudflare says jobs may run for up to seven days and results remain available for fourteen days. That temporal shape belongs in the catalogue contract. A row that blocks a model turn until an entire crawl completes is the wrong projection.

Use three capabilities instead:

```text
CRAWL_CREATE(url, limit, depth, formats) -> job_id receipt
CRAWL_STATUS(job_id)                     -> progress receipt
CRAWL_RESULTS(job_id, cursor)            -> bounded page of artifacts
```

The catalogue can project those rows into an asynchronous REST API, terminal commands and model tools while retaining one authority policy and one lineage chain. Each result page should point back to the create receipt and catalogue snapshot.

Cloudflare's current Free limits specify five crawl jobs per day and one hundred pages per crawl. A March 2026 Hacker News comment multiplied those two numbers and questioned a 500-page daily ceiling. That is a reasonable reading of the Free limits now published, but the commenter described the documentation they saw and framed the concern more broadly. It is not independent evidence of a paid-plan cap. The current limits page says paid defaults can be increased and does not list the same crawl-specific table under Paid. The article therefore narrows the anecdote instead of repeating it as a current universal limit.

A second operator built a two-script, zero-dependency CLI covering all nine REST endpoints then documented, including `/crawl`. That externally attests that the REST surface was usable as a coherent toolset for one builder. It does not prove our adapter, our credentials or today's endpoint. Our own proof remains the measured `/markdown` receipt above.

## The authority boundary is larger than the URL

A browser can send cookies, custom headers, HTTP credentials and injected scripts. It can follow redirects to a different host, load subresources from many hosts, download data, and execute code supplied by the destination. Treating authority as an allowlist on the initial URL is inadequate.

The minimum policy envelope includes:

| Boundary | Required control |
| --- | --- |
| scheme | allow `https:`; reject `file:`, `data:`, local protocols |
| destination | resolve DNS and reject private, loopback, link-local and metadata addresses |
| redirects | revalidate every redirect target |
| subresources | block or constrain hosts when the task permits |
| credentials | declare exactly which cookies, headers or HTTP auth may leave |
| scripts | prohibit untrusted catalogue rows from injecting code |
| downloads | disable or quarantine with size and type limits |
| duration | bounded navigation, selector and overall operation timeouts |
| wallet | per-invocation browser-ms budget and caller quota |
| output | byte limit, format validation, hashing and secret scan |

This is SSRF defense and denial-of-wallet defense in one place. The model should never receive a raw “browse any URL with these headers” primitive when a narrower row can express the job. A malicious directory row must not be able to expand its own host authority, supply a metadata address, or ask the adapter to return cookies in the receipt.

The receipt should be useful without becoming a credential leak:

```json
{
  "capability_key": "BROWSER_MARKDOWN",
  "catalogue_version": "sha256:…",
  "requested_url": "https://example.com",
  "final_url": "https://example.com/",
  "authority_policy": "public-docs-v3",
  "completion": {"kind": "heading", "observed": true},
  "format": "markdown",
  "http_status": 200,
  "browser_ms_used": 207.706,
  "elapsed_ms": 1418,
  "body_sha256": "sha256:…",
  "credentials": {"token": "redacted", "cookies_sent": false}
}
```

Replay means invoke the same capability version with the same public inputs and policy, then compare receipts. It does not mean persist and resend an expired token. Repair means change the row or adapter under review—perhaps the selector, format, timeout or allowed host—then issue a new catalogue version and preserve the failed receipt. The ledger makes failure part of lineage instead of rewriting history.

## REST for bounded transforms; Puppeteer for interaction

Quick Actions cover common one-shot representations with smaller contracts. Puppeteer or Playwright is appropriate when the task genuinely requires interaction across states: click, type, authenticate, paginate, reuse a session, inspect requests, or coordinate multiple pages.

| Requirement | Prefer | Reason |
| --- | --- | --- |
| one URL to Markdown | Quick Action | bounded request and direct browser-time receipt |
| screenshot plus HTML | `/snapshot` | representations share one capture |
| named CSS fields | `/scrape` | selector contract is explicit |
| multi-page site collection | `/crawl` | asynchronous job semantics |
| click through a flow | Puppeteer/Playwright | stateful interaction |
| persistent authenticated workspace | reusable session | cookies and state are intentional |
| stable public JSON endpoint | ordinary `fetch()` | no browser evidence is needed |

The decision can be mechanized in the canonical catalogue. Discovery exposes the specific transform first. Authority hides session tools from callers that do not need credentials or interaction. Invocation validates URL and completion conditions. Receipts normalize REST and session results into the same lineage fields. Repair can replace an implementation without changing the capability's public meaning.

This is where the system claim becomes concrete. One catalogue row drives discovery text, input schema, authority, adapter selection, receipt shape, replay, repair documentation, model-tool projection, CLI help and admin controls. The browser is not a second architecture. It is one implementation family behind the catalogue.

## What the operator reports change—and what they do not

The people-source set is deliberately mixed.

- A Cloudflare engineer reported `browser.close()` hanging in local Vite and Wrangler development while production succeeded. This supports testing local and deployed lifecycle separately.
- A user reported REST error codes 7003 and 7000 despite a token and account identifier they had verified. This supports returning Cloudflare's structured error body and the chosen endpoint in the receipt; it does not prove the present API is generally misconfigured.
- A crawl user reported malformed root-relative image URLs in Markdown while HTML stayed correct. This supports cross-format validation.
- A commenter questioned crawl throughput based on the published limit arithmetic. This supports showing the limit calculation and reading the current plan table, not a universal paid-plan conclusion.
- A CLI author reported exercising the full REST family. This supports the coherence of Quick Actions as a practical interface for that author.
- Two other commenters described using or observing Browser Rendering for scraping and Markdown distillation. These support the use case, not permission, bypass success, commercial scale or adoption.

Operator evidence is valuable here because it reveals failure modes absent from a happy-path reference: lifecycle hangs, auth-shaped errors, converter defects and throughput surprises. It remains externally attested evidence. The specification defines the contract; the fresh receipt establishes what this build reproduced; operator reports tell us which edges deserve tests.

## The operating rule

Use Browser Run when the thing you need does not exist until a browser executes the page, or when the required artifact is browser-specific. Name the representation. Name the completion condition. Constrain the authority. Meter browser time. Preserve the source alongside every derived form.

Do not call it a bypass. Do not call model-shaped JSON fact. Do not call one successful render availability. Do not call a historical issue a current universal defect.

When those boundaries are encoded once in the capability catalogue, the same browser operation can be discovered by a model, invoked from a terminal, projected as an API, receipted in the ledger, replayed after a change and repaired without losing its history. That—not remote Chrome by itself—is what makes Browser Rendering part of an operating system.

## Sources

1. Browser Run Quick Actions overview — https://developers.cloudflare.com/browser-run/quick-actions/
2. /markdown — Extract Markdown from a webpage — https://developers.cloudflare.com/browser-run/quick-actions/markdown-endpoint/
3. /snapshot — Capture multiple page formats — https://developers.cloudflare.com/browser-run/quick-actions/snapshot/
4. /content — Fetch rendered HTML — https://developers.cloudflare.com/browser-run/quick-actions/content-endpoint/
5. /accessibilityTree — Capture the accessibility tree — https://developers.cloudflare.com/browser-run/quick-actions/accessibility-tree-endpoint/
6. /scrape — Scrape HTML elements — https://developers.cloudflare.com/browser-run/quick-actions/scrape-endpoint/
7. /json — Capture structured data using AI — https://developers.cloudflare.com/browser-run/quick-actions/json-endpoint/
8. /crawl — Crawl web content — https://developers.cloudflare.com/browser-run/quick-actions/crawl-endpoint/
9. Browser Run pricing — https://developers.cloudflare.com/browser-run/pricing/
10. Browser Run limits — https://developers.cloudflare.com/browser-run/limits/
11. Puppeteer on Browser Run — https://developers.cloudflare.com/browser-run/puppeteer/
12. /screenshot — Capture a screenshot — https://developers.cloudflare.com/browser-run/quick-actions/screenshot-endpoint/
13. miscsubjects architecture — https://github.com/redacted/miscsubjects-architecture
14. Cloudflare crawl endpoint — https://hn.algolia.com/api/v1/items/47332926
15. BUG: browser rendering browser.close() hangs — https://github.com/cloudflare/workers-sdk/issues/9945
16. Cloudflare Browser Rendering API (Code 7003/7000) Failure in Worker — https://github.com/cloudflare/workers-sdk/issues/10864
17. Browser Rendering /crawl API: Markdown converter incorrectly resolves root-relative image URLs — https://github.com/cloudflare/workers-sdk/issues/13406
18. Perplexity is using stealth, undeclared crawlers to evade no-crawl directives — https://hn.algolia.com/api/v1/items/44788890
19. Cloudflare crawl endpoint — https://hn.algolia.com/api/v1/items/47348398
20. ChatGPT won't let you type until Cloudflare reads your React state — https://hn.algolia.com/api/v1/items/47572417
21. Fresh first-party Browser Run /markdown receipt — https://miscsubjects.com/api/articles/cloudflare-os-browser


---

# waitUntil, Queues, Workflows or Cron: choose by durability

slug: cloudflare-os-async · https://miscsubjects.com/a/cloudflare-os-async · tags: cloudflare, architecture, queues, cloudflare-os · updated 2026-07-26T03:59:38.467Z

A request has to return now. The work behind it takes ninety seconds, or ten minutes, or has to happen at 4am whether or not anyone visits. Cloudflare gives you four ways to move that work off the response path, and they are not interchangeable: pick the wrong one and you either lose the job silently, pay for durability you never needed, or discover in production that the thing you tested locally cannot exist there.

This page settles the choice, gives the working configuration for each, and publishes the measured behaviour of the two that run in this account.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Four mechanisms, and the one property that decides between them

The property is **durability** — whether the work survives the death of the invocation that started it. Duration is the second question, not the first.

| | `ctx.waitUntil` | Queue (`queue()` consumer) | Workflow | Cron Trigger |
| --- | --- | --- | --- | --- |
| Survives the request's Worker dying | No | Yes — the message is persisted before `send()` resolves | Yes — each completed step is persisted | N/A, nothing starts it but the clock |
| Retry semantics | None. The promise is cancelled | Whole batch retried; `max_retries` default 3; per-message `ack()` / `retry()` | Per-step; default `limit: 5`, `delay: 10000`, `backoff: "exponential"`, `timeout: "10 minutes"` | None. A failed tick is a lost tick |
| Maximum duration | 30 s after the response is sent | 15 minutes wall clock per consumer invocation | Unlimited wall clock per step; `step.sleep` up to 365 days | 15 minutes wall clock |
| Ordering | N/A | **Not guaranteed** | Guaranteed within one instance — single-threaded | By schedule only |
| Delivery guarantee | None | At-least-once | At-least-once per step; the step *result* is cached, so a completed step is not re-executed | At-least-once |
| Observability | Workers Logs only | Queue metrics, DLQ contents, consumer logs | `wrangler workflows instances list/describe`, REST API, dashboard | Past Cron Events (last 100), Workers Logs, GraphQL Analytics API |
| Cost unit | Nothing beyond the parent request | $0.40 per million operations; one message ≈ 3 operations (write, read, delete) | Requests + CPU ms + GB-month storage + $0.80 per additional 100,000 steps | One Worker request per tick |
| Redeploy mid-flight | Undocumented; assume the in-flight promise is lost | Unacked messages are redelivered to the new code | Undocumented. The step journal survives, so completed steps are not re-run, but a changed step list is unhandled | Next tick runs the new code |

Two rows in that table are marked undocumented, and that is a real gap rather than a research failure. Tim, writing at thisisacomputer.com, tried to find the answer for Workflows and reported: "Making changes to durable workflows is tricky. Cloudflare has no documentation around this. You're on your own, so be careful." His own working rules — appending a step at the end is safe, inserting one in the middle is probably not — are inference, and he labels them as inference. Treat them the same way.

## `ctx.waitUntil` buys thirty seconds and no promises

`ctx.waitUntil(promise)` tells the runtime to keep the invocation alive after the response has been sent. It is the third argument to every handler (`fetch(request, env, ctx)`), and it is not storage: nothing is written anywhere, and there is no retry.

The limit is hard and shared. From the Context API reference: "For HTTP-triggered Workers, `ctx.waitUntil()` can extend execution for up to 30 seconds after the response is sent or the client disconnects. This is not a limit on the total wall time of an HTTP request. This time limit is shared across all `waitUntil()` calls within the same request."

When you exceed it, the promises are cancelled and this exact line appears in Workers Logs:

```
waitUntil() tasks did not complete within the allowed time after invocation end and have been cancelled.
```

Cloudflare's own docs name the escape hatch in the same paragraph: "If the work cannot finish within the `waitUntil()` time limit, send messages to a Queue and process them in a separate consumer Worker."

This build uses `waitUntil` where losing the work costs nothing — cache warming and snapshot refresh in `functions/_middleware.js`, event logging in `functions/_lib/event_log.js`, ledger writes in `functions/api/dispatch.js`. Here is the real pattern from `functions/_middleware.js`, where a slow render is allowed to finish after a cached fallback has already been served:

```js
// functions/_middleware.js
context.waitUntil(
  render
    .then((late) =>
      late && late.status === 200 ? refreshLastGood(env, key, late) : null,
    )
);
```

If that promise dies, the next request re-renders. Nothing is lost that matters. That is the only test that licenses `waitUntil`.

## A queue is the cheapest thing that survives your Worker dying

A queue has two halves. The **producer** holds a binding and calls `send()`. The **consumer** exports a `queue()` handler and is invoked with batches. Both halves can live in the same Worker.

Configuration, in `wrangler.toml`:

```toml
[[queues.producers]]
binding = "TASKS"
queue = "loop-tasks"

[[queues.consumers]]
queue = "loop-tasks"
max_batch_size = 10
max_batch_timeout = 5
max_retries = 3
dead_letter_queue = "loop-tasks-dlq"
```

Create the queues first, or the deploy fails:

```sh
npx wrangler queues create loop-tasks
npx wrangler queues create loop-tasks-dlq
```

Expected output for each: `Creating queue 'loop-tasks'.` followed by `Created queue 'loop-tasks'.`

The producer. `send()` resolves once the message is durably written, so awaiting it is what makes the handoff safe:

```js
// producer — returns immediately, work is now someone else's problem
export async function onRequestPost({ env }) {
  await env.TASKS.send({ key: "REBUILD_INDEX", body: "", ts: Date.now() });
  return Response.json({ queued: true }, { status: 202 });
}
```

The consumer. `ack()` per message is the difference between one poison message and ten redeliveries:

```js
export default {
  async queue(batch, env) {
    for (const msg of batch.messages) {
      try {
        await doTheWork(msg.body, env);
        msg.ack();          // this message will not be redelivered
      } catch {
        msg.retry();        // only this message goes back on the queue
      }
    }
  },
};
```

Without the per-message `ack()`, one failure takes the whole batch with it. The docs are explicit: "if a batch of 10 messages is delivered, but the 8th message fails to be delivered, all 10 messages will be retried and thus redelivered to your consumer in full."

`max_batch_size` and `max_batch_timeout` race each other — whichever is reached first triggers delivery. With the defaults (10 messages, 5 seconds) a low-traffic queue always waits out the timeout. That is exactly what the enqueue-to-consumption measurement below shows.

Two properties will bite you if you skim them. **Order is not preserved:** "Queues does not guarantee that messages will be delivered to a consumer in the same order in which they are published." **Delivery is at-least-once**, not exactly-once: "messages are guaranteed to be delivered at least once, and in rare occasions, may be delivered more than once." The documented fix is an idempotency key generated at write time and used as the primary key or the upstream API's idempotency header — not a de-duplication table you maintain yourself.

## Workflows pay for durability one step at a time

A Workflow is a class extending `WorkflowEntrypoint` with a `run(event, step)` method. Every `step.do(name, fn)` result is persisted. If the instance dies and resumes, completed steps return their cached value instead of re-executing. The step name is the cache key, which is why the docs insist names be deterministic.

Configuration:

```toml
[[workflows]]
name = "deliver-workflow"
binding = "DELIVER_WF"
class_name = "DeliverWorkflow"
```

The code, from `workers/sibling/src/index.js` in this build, trimmed to the shape:

```js
import { WorkflowEntrypoint } from 'cloudflare:workers';

export class DeliverWorkflow extends WorkflowEntrypoint {
  async run(event, step) {
    const tickAt = await step.do('record start', async () => buildNowIso());

    const pending = await step.do('list pending', async () => {
      const r = await this.env.DB.prepare(
        "SELECT id, asset_id, channel, recipient FROM pending_deliveries " +
        "WHERE status IN ('queued','polling') ORDER BY id LIMIT 25"
      ).all();
      return (r.results || []).map(x => ({ id: x.id, channel: x.channel }));
    });

    for (const job of pending) {
      await step.do(`deliver ${job.id}`,
        { retries: { limit: 3, delay: '10 seconds', backoff: 'exponential' } },
        async () => {
          const resp = await fetch(PAGES_BASE + '/api/deliver', {
            method: 'POST', headers: deliverHeaders(this.env),
            body: JSON.stringify({ id: job.id }),
          });
          return { id: job.id, status: resp.status };
        });
    }
    return { tickAt, attempted: pending.length };
  }
}
```

Note `step.do(\`deliver ${job.id}\`)`. The name is dynamic but deterministic — it comes from a database row id, traversed in a fixed order. A name built from `Date.now()` or `Math.random()` would never hit its cache and would re-run the side effect on every resume.

Pacing uses `step.sleep`, which costs nothing while it waits. The sibling Worker's self-test workflow spaces its questions this way:

```js
await step.sleep(`pace ${i}`, '30 seconds');
```

A sleeping instance does not count against the concurrency limit: "Instances that are in a `waiting` state — either sleeping via `step.sleep`, waiting for a retry, or waiting for an event via `step.waitForEvent` — do **not** count towards concurrency limits."

Trigger and inspect from the command line:

```sh
npx wrangler workflows trigger deliver-workflow '{"reason":"manual"}'
npx wrangler workflows instances list deliver-workflow
npx wrangler workflows instances describe deliver-workflow <INSTANCE_ID>
```

`instances describe` is the one that shows per-step status. The binding's `instance.status()` does not — it returns only queued/running/complete plus the output or error.

The published limits moved substantially between August 2025 and now, and the older independent write-up is still the top search result, so both numbers are here. Tim measured against the platform as it stood in 2025-08: "You're limited to 25 concurrent instances on the free tier or 4500 on the paid tier" and "1024 steps per workflow". The current limits page says 100 concurrent on Free and 50,000 on Paid, with 1,024 steps on Free and 10,000 (configurable to 25,000) on Paid. Both are accurate for their date. The lesson is to read the limits page on the day you design, not the blog post.

## Cron is the only one that starts itself

A Cron Trigger maps a five-field cron expression to a `scheduled()` handler. It runs on UTC. Nothing invokes it but the clock.

```toml
[triggers]
crons = ["*/1 * * * *", "0 4 * * *"]
```

```js
export default {
  async scheduled(controller, env, ctx) {
    if (controller.cron === '0 4 * * *') {
      ctx.waitUntil(fetch(BASE + '/api/daily-report', { method: 'POST' }));
      return;
    }
    ctx.waitUntil(fetch(BASE + '/api/tick', { method: 'POST' }));
  },
};
```

`controller.cron` is the string that fired, so one handler serves every schedule. `controller.scheduledTime` is the intended fire time in epoch milliseconds — use that, not `Date.now()`, when a tick must be idempotent, because a retried invocation carries the same `scheduledTime`.

**The minimum interval is one minute.** `* * * * *` is the finest expression the five-field syntax allows. Anything faster needs a Durable Object alarm.

**Overlap is not prevented.** Cloudflare does not skip or queue a tick because the previous one is still running. If your job can exceed its interval, you must gate it yourself. This build gates with a KV flag read at the top of each branch, so a disabled loop costs one KV read and nothing else:

```js
const on = env.KV ? await env.KV.get('writer_queue_autorun') : null;
if (on !== '1') return;
```

A second pattern in the same handler thins a per-minute schedule down to a five-minute one without adding a second cron entry:

```js
if (on === '1' && new Date().getMinutes() % 5 === 0) { /* ... */ }
```

**Seeing whether a tick ran.** The dashboard keeps only the last 100 invocations under **Settings → Trigger Events → View events**, and takes up to 30 minutes to start showing anything for a new Worker. `npx wrangler tail loop-safe-sibling --format=pretty` shows them live. Neither is a record. This build writes its own row instead, into a D1 `log` table, which is what made the measurements at the bottom of this page possible.

**Deploy semantics are destructive.** From the Cron Triggers docs: "When deploying a Worker with Wrangler any previous Cron Triggers are replaced with those specified in the `triggers` array." An empty `crons` array deletes them all; omitting the key entirely leaves them alone. And changes take up to 15 minutes to propagate.

## There is no way to dead-letter a message you already know is poison

A dead-letter queue only receives a message after `max_retries` is exhausted. The DLQ docs say so plainly: a DLQ "represents where messages are sent when a delivery failure occurs with a consumer after `max_retries` is reached."

That leaves a hole. When your consumer reads a message and knows immediately — malformed JSON, a deleted tenant, a schema version you no longer support — that retrying is pointless, there is no API to send it straight to the DLQ. `alexander-zuev` filed it on `cloudflare/workers-sdk` as issue 13816: "Cloudflare Queues push consumers currently expose ack/retry controls, and configured DLQs receive messages only after max_retries is exhausted."

Three workarounds exist. None is clean.

| Workaround | What it costs | What you lose |
| --- | --- | --- |
| `msg.ack()` and drop it | Nothing | The payload. No record of what failed, nowhere to replay it from |
| `msg.retry()` until retries are exhausted | 3 extra read operations per message, plus 3 more consumer invocations, plus the wall-clock delay before it lands | Nothing, eventually — but your consumer logs fill with failures that were never going to succeed |
| `env.FAILURES.send(msg.body)` then `msg.ack()` | One extra queue, and ~3 operations per failed message | Nothing. You now own the retention policy and the replay path |

**Pick the third.** The arithmetic decides it: burning retries costs roughly the same operations as writing to a parallel failure queue, and buys you nothing except latency and noise. Explicitly writing the failure gives you the same durable record a DLQ would have given you, plus the failure reason, which a real DLQ does not carry.

```js
async queue(batch, env) {
  for (const msg of batch.messages) {
    let job;
    try { job = JSON.parse(msg.body); }
    catch (e) {
      await env.FAILURES.send({ raw: msg.body, reason: 'unparseable', at: Date.now() });
      msg.ack();                       // non-retryable — do not burn retries
      continue;
    }
    try { await run(job, env); msg.ack(); }
    catch (e) { msg.retry(); }         // transient — this one deserves retries
  }
}
```

Keep a real DLQ configured as well. It catches the transient failures that genuinely exhaust their retries, which is the case a DLQ was designed for. Messages sitting in a DLQ with no consumer attached are deleted after four days.

## Declaring a queue producer breaks every route under `--remote`

This is the failure that costs the most time, because it looks like your code.

`tobihagemann` filed issue 9642 on `cloudflare/workers-sdk`: "When a queue producer is configured in `wrangler.toml`, ALL API routes return 500 Internal Server Error when using `wrangler dev --remote`, even routes that don't use the queue binding." Eight reactions. Local dev is fine. Production is fine. Only `--remote` breaks, and it breaks routes that never touch the binding.

It compounds. `Cherry` filed the wider version as issue 5543: "If you run a worker that uses Queues with `dev --remote`, it implodes and is completely unusable. You get obscure \"Script not found\" errors." Because several bindings — Browser Rendering and Analytics Engine among them — only function under `--remote`, a Worker that uses Queues *and* Browser Rendering cannot be exercised end to end in development at all.

This build is exactly that Worker. `workers/sibling/wrangler.toml` declares `[[queues.producers]]`, `[[queues.consumers]]`, and `[browser] binding = "MYBROWSER"` in the same file.

**The workaround is to stop trying to run one session.** Split the surface:

1. Run everything else in the local emulator: `npx wrangler dev` (Miniflare runs the same Queues implementation Cloudflare runs globally, and the local queue actually delivers to your local consumer).
2. For the `--remote`-only bindings, put them behind a small separate Worker with no queue bindings in its config, and run *that* with `npx wrangler dev --remote`. Call it over a service binding.
3. Test the queue path itself against a preview deployment rather than a dev session: `npx wrangler deploy --name my-worker-preview` and drive it with real requests.

Related, and worth knowing before you debug it for an hour: `danieltroger` filed issue 14101, where under local `wrangler dev` "a ~200 KB `Uint8Array` step output fails with `string or blob too big: SQLITE_TOOBIG`, but the same bytes as an `ArrayBuffer` (or a 2 MB string) succeed." The local Workflows engine stores step results in SQLite and the serialization path for typed arrays is what breaks, not the size limit you would expect from the 1 MiB documented ceiling.

## Ship payments somewhere else; keep Workflows for the cheap reports

Two credible criticisms of Workflows' production readiness exist, and they point in different directions.

The first is about lifecycle. Commenting on Hacker News, `aroman` wrote that Cloudflare was "claiming Workflows had reached \"GA\" status before offering a way to delete workflows... not via wrangler, not the dashboard, not the API." That gap has since closed: `wrangler workflows delete [NAME]` is documented today, with the note "when deleting a workflow, it will also delete it's own instances", alongside `instances terminate`, `pause`, `resume` and `restart`. The complaint was accurate when made and is no longer a blocker. What survives it is the pattern — check that the operation you will need at 3am exists before you build on the primitive, not after.

The second is about where Workflows belongs, and it is the more useful one because it comes with a policy already in production. `saxenaabhi`, on the "Building durable workflows on Postgres" thread, splits work across three durable-execution engines and uses Cloudflare Workflows for exactly one of them: payments go to Restate "since its faster than cf workflows, independent of cf and its downtime and self-hostable vendor-lock-in free", Workflows handles non-critical CSV and PDF report generation because it is very cheap, and DBOS covers the cases that need atomicity with a Postgres transaction.

**That boundary is the recommendation of this page.** Use Cloudflare Workflows when the job is (a) already inside Cloudflare, (b) tolerant of Cloudflare being down, and (c) cheap enough that the price advantage is the point. Move it out when a Cloudflare outage means the job must still run — a payment, a regulatory filing, an SLA-bound callback — because a durable execution engine that is unavailable is not durable from the caller's side. That is a decision about correlated failure, not about features.

## What this build actually runs on a schedule

The Pages project (`wrangler.toml`) has no `[triggers]` block at all. Pages Functions have no `scheduled()` handler. Every scheduled action is owned by one bound Worker, `loop-safe-sibling`, at `workers/sibling/`.

```toml
# workers/sibling/wrangler.toml
[triggers]
# */1 = build ticks · 0 4 * * * = 9:00 PM America/Los_Angeles (PDT → 04:00 UTC)
crons = ["*/1 * * * *", "0 4 * * *"]
```

| Cron line | UTC meaning | What it does | Where |
| --- | --- | --- | --- |
| `*/1 * * * *` | every minute | Writes a `sibling.cron` row to D1, fires `/api/deliver`, then fans out 11 more gated jobs — task runner, protocol writer, OIP review, editorial board, article Q&A, writer queue, graph grow, GitHub loop, commit fold, automation sweep | `workers/sibling/src/index.js:351` |
| `0 4 * * *` | 04:00 UTC = 21:00 America/Los_Angeles during PDT | Posts the daily Stripe summary to WhatsApp, then returns without running the per-minute fan-out | `workers/sibling/src/index.js:354` |

Every job in the per-minute fan-out is wrapped in `ctx.waitUntil` and gated on a KV flag, so a disabled loop is one KV read. That is the whole design: cron provides the heartbeat, `waitUntil` provides the parallelism, KV provides the switch, and nothing in the tick is allowed to be work that matters if it is lost. Work that matters goes to `env.TASKS.send()` at `functions/_lib/fn_runners.js:1146`, or to a Workflow.

## Durable Object alarms win when the schedule belongs to one entity

A fifth option, and often the right one. A Durable Object schedules its own wake-up with `setAlarm()`, and the runtime calls its `alarm()` handler at that time. Alarms have "guaranteed at-least-once execution and are retried automatically when the `alarm()` handler throws", with "exponential backoff starting at a 2 second delay from the first failure with up to 6 retries allowed".

Choose an alarm over a cron when the schedule is per-entity rather than global: one user's trial expiry, one document's autosave, one game's tick. A Worker gets three Cron Triggers; an account gets an unbounded number of Durable Objects, each with its own alarm. Choose an alarm over a queue when the work needs the co-located strongly-consistent storage the object already holds.

Alarms also have the worst failure mode of anything on this page — a self-rescheduling alarm that never terminates bills continuously and silently. The mechanics of that, and the $34,895 case, are in [Workers, Durable Objects and the cost of getting the object model wrong](/a/cloudflare-os-workers). Read it before you write your first `setAlarm()`.

## Answer five questions in order and the mechanism is decided

1. **Does the work start from a request, or from the clock?** From the clock, globally → **Cron Trigger**. From the clock, per-entity → **Durable Object alarm**. From a request → keep going.
2. **If this work is silently lost, does anything break?** No → **`ctx.waitUntil`**. Stop here; it is free and it is one line. Yes → keep going.
3. **Will it finish inside 30 seconds after the response?** No → skip to 4. Yes, but it must not be lost → still skip to 4. `waitUntil` has no retry, so "must not be lost" always leaves it.
4. **Is it one unit of work, or several with side effects between them?** One unit, idempotent, under 15 minutes → **Queue**. Several steps where re-running step 3 after step 4 fails would double-charge, double-send, or double-write → **Workflow**.
5. **Must it still run when Cloudflare is down?** Yes → an external durable-execution engine, and accept the operational cost. No → the answer from step 4 stands.

The common wrong turn is step 4. A queue retry re-runs your *entire* consumer body for that message. If that body has already sent an email and then fails at the database write, the retry sends a second email. A Workflow's `step.do` is the only mechanism here that stops that, because the completed step returns its cached result instead of re-executing.

## The bill, per mechanism, with the arithmetic

| Mechanism | Rate | Worked example |
| --- | --- | --- |
| `ctx.waitUntil` | No separate charge. CPU time counts against the parent request | 1M requests each doing a 5 ms `waitUntil` write: no line item, the CPU already counted |
| Queues | $0.40 per million operations; 1M operations/month included on Paid. One 64 KB message = 3 ops (write, read, delete) | 1M messages/month = 3M ops − 1M included = 2M billed = **$0.80/month** |
| Queues, with retries | Each retry adds one read op per message | Same 1M messages, 5% failing and retried 3× before the DLQ write: +150,000 reads +50,000 DLQ writes ≈ 2.2M billed ≈ **$0.88/month** |
| Workflows | 10M requests + 30M CPU-ms + 500,000 steps + 1 GB storage included on Paid; then $0.30/M requests, $0.02/M CPU-ms, $0.80 per additional 100,000 steps, $0.20/GB-month | 100,000 instances/month × 8 steps = 800,000 steps − 500,000 included = 300,000 billed = 3 × $0.80 = **$2.40/month** in steps, before CPU |
| Cron Triggers | One Worker request per tick, at the standard Workers rate | `*/1 * * * *` = 1,440 requests/day = 43,800/month, inside the 10M included on Paid = **$0** marginal |

Two notes the tables hide. Workflows step and storage billing is not live yet — Cloudflare's pricing page states billing "will apply starting August 10th, 2026", so the $2.40 above is what that workload will cost, not what it costs today. And a queue message over 64 KB is charged as multiple messages: a 127 KB message incurs two operation charges on every write, read and delete.

The number that should decide anything here is not the monthly total — all four are cheap at small scale. It is the retry multiplier. A consumer that throws on a permanently broken message costs you 4× the reads and 4× the invocations for a message that was never going to succeed, forever, until you fix it.

## Error strings and what each one means

| Symptom | Cause | Fix |
| --- | --- | --- |
| `waitUntil() tasks did not complete within the allowed time after invocation end and have been cancelled.` | Total `waitUntil` work in one request exceeded 30 s | Move the work to a queue. The limit is shared across every `waitUntil` in that request, so splitting into more calls does not help |
| Every route returns 500 under `wrangler dev --remote`, including routes with no queue code | A `[[queues.producers]]` binding exists in the config — `workers-sdk` issue 9642 | Do not use `--remote` on a Worker with queue bindings. Use plain `npx wrangler dev`, or a preview deployment |
| `Script not found` from `wrangler dev --remote` on a Worker with Queues | Same root cause, wider blast radius — `workers-sdk` issue 5543 | Split the `--remote`-only bindings into a second Worker without queue bindings and reach it over a service binding |
| `string or blob too big: SQLITE_TOOBIG` from a Workflow step under local `wrangler dev` | A `Uint8Array` step output around 200 KB hits the local SQLite serialization path — `workers-sdk` issue 14101 | Return an `ArrayBuffer` or a string instead, or write the bytes to R2 and return the key |
| `Too Many Requests` thrown by `send()` or `sendBatch()` | Per-queue throughput ceiling of 5,000 messages/second exceeded | Batch with `sendBatch()` (100 messages or 256 KB per call), or shard across queues |
| `Storage Limit Exceeded` from `send()` | The queue backlog hit 25 GB — the consumer is not keeping up | Raise consumer concurrency, raise `max_batch_size`, or shed load at the producer |
| A message reappears after your consumer already processed it | At-least-once delivery, as designed | Generate an id at write time and use it as the database primary key or the upstream API's idempotency key |
| Messages arrive out of order | Queues does not preserve publish order | Do not encode order in the queue. Sequence in the payload, or use a Workflow |
| A Workflow re-runs a step that already succeeded | The step name is non-deterministic — built from a timestamp, a random value, or an unordered iteration | Name steps from stable data, traversed in a fixed order. The name is the cache key |
| A cron schedule change does not take effect | Cron Trigger propagation takes up to 15 minutes | Wait. Verify with `npx wrangler tail <worker-name>` rather than redeploying repeatedly |
| Cron Triggers vanished after a deploy | `crons` was set to `[]`, which deletes all of them | Restore the array. Omitting `triggers` entirely leaves existing triggers in place; an empty array removes them |

## Measurements taken from this account

Four measurements, each rerunnable. The account id and the `workers.dev` subdomain are redacted; nothing else is.

**1 — The async surface of this repository.** Every command run from the repository root.

```sh
grep -rIn "waitUntil(" --include="*.js" functions/ workers/sibling/src/ | grep -v node_modules | wc -l
# 30

grep -rIl "waitUntil(" --include="*.js" functions/ workers/sibling/src/ | grep -v node_modules | wc -l
# 9

awk 'NR>=351 && NR<=466' workers/sibling/src/index.js | grep -c "ctx.waitUntil("
# 13   — all inside a single scheduled() handler

grep -rn "^crons" wrangler.toml workers/*/wrangler.toml
# workers/sibling/wrangler.toml:12:crons = ["*/1 * * * *", "0 4 * * *"]

grep -rn "queues.consumers\|\[\[workflows\]\]" wrangler.toml workers/*/wrangler.toml
# workers/sibling/wrangler.toml:63:[[queues.consumers]]
# workers/sibling/wrangler.toml:50:[[workflows]]
# workers/sibling/wrangler.toml:55:[[workflows]]
```

Thirty `waitUntil` call sites across nine files, thirteen of them in one scheduled handler; two cron expressions; one queue consumer; two Workflow classes; zero cron triggers on the Pages project.

**2 — Queues on the account.**

```sh
npx wrangler queues list
```

Three queues: `loop-ingest` (4 producers, 1 consumer), `loop-ingest-dlq` (0 producers, 1 consumer), `loop-tasks` (3 producers, 1 consumer). The DLQ has a consumer attached, which is the only configuration in which a DLQ is more than a four-day holding pen.

**3 — Cron delivery over 21 hours 39 minutes: 1,300 of 1,300 ticks, zero missed.** The `*/1 * * * *` trigger writes one row per tick to a D1 `log` table at `workers/sibling/src/index.js:363`, which makes delivery auditable without the dashboard's 100-event window.

```sh
npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT COUNT(*) ticks, MIN(ts) first_ts, MAX(ts) last_ts \
             FROM log WHERE key='sibling.cron' AND ts >= '2026-07-25T00:00:00-07:00'"
```

Returned `ticks: 1300`, `first_ts: 2026-07-25T00:00:07-07:00`, `last_ts: 2026-07-25T21:39:01-07:00`. That span is 1,299 minutes, so 1,300 ticks inclusive of both endpoints is the exact expected count. No tick was dropped.

**4 — Within-minute jitter across the last 1,000 ticks: 99.4% inside 7 seconds.**

```sh
npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT substr(ts,18,2) AS sec, COUNT(*) n FROM \
             (SELECT ts FROM log WHERE key='sibling.cron' ORDER BY id DESC LIMIT 1000) \
             GROUP BY sec ORDER BY n DESC"
```

642 ticks landed at `:01`, 352 at `:07`, and 6 were spread across `:08` to `:12`. The worst observed lateness in 1,000 consecutive ticks was 12 seconds. A per-minute cron is punctual enough to schedule against, and nowhere near punctual enough to sequence against.

**5 — Enqueue to consumption: 6 to 8 seconds, median 7.** Method: `POST /api/dispatch {"key":"QUEUE_SEND","body":"NOW|"}` calls `env.TASKS.send()` at `functions/_lib/fn_runners.js:1146` and returns the enqueue timestamp inside the job body. The consumer at `workers/sibling/src/index.js:466` re-dispatches the job, which writes its own invocation receipt. The difference between the two timestamps is the queue's end-to-end latency.

```sh
curl -sS -X POST "https://miscsubjects.com/api/dispatch" \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' \
  --data '{"key":"QUEUE_SEND","body":"NOW|"}'
# {"queued":true,"job":{"key":"NOW","body":"","ts":"2026-07-25T21:41:28-07:00"}}

sleep 25
curl -sS "https://miscsubjects.com/api/invocations?object_id=NOW&limit=1" \
  -H "x-terminal-key: $TERMINAL_KEY"
# ts: 2026-07-25T21:41:34-07:00
```

| Sample | Enqueued | Consumed | Latency |
| --- | --- | --- | --- |
| 1 | 21:40:58 | 21:41:06 | 8 s |
| 2 | 21:41:28 | 21:41:34 | 6 s |
| 3 | 21:41:55 | 21:42:02 | 7 s |
| 4 | 21:42:23 | 21:42:30 | 7 s |

Median 7 seconds, on a queue configured `max_batch_size = 10`, `max_batch_timeout = 5`. Every sample was a single message, so the batch never filled and the 5-second timeout governed every delivery. The 1–3 seconds above the timeout is consumer cold start plus the downstream dispatch.

That number is the honest cost of a queue handoff on an idle queue: your user's request returns in milliseconds, and the work starts about seven seconds later. If that gap is unacceptable, `max_batch_timeout = 0` removes it at the price of one consumer invocation per message.

Context for where these four mechanisms sit in the rest of the platform: [the Cloudflare stack, indexed](/a/cloudflare-os).

## Fresh receipt: 1,000 consecutive minute ticks occupied 1,000 minute slots

Wrangler 4.103.0 and a read-only filesystem harness reran the inventory at `2026-07-26T06:02:38.239Z`. Before querying the live `log` table, the harness read `PRAGMA table_info(log)` and confirmed the columns `id`, `ts`, `trace`, `step`, `parent`, `key`, `type`, `input`, and `output`.

| Fresh check | Result |
| --- | --- |
| JavaScript `waitUntil(` call sites | 30 across 9 files |
| `ctx.waitUntil(` inside the sibling `scheduled()` handler | 13 |
| Cron expressions | `*/1 * * * *` and `0 4 * * *` |
| Workflow bindings | `DELIVER_WF`, `SELFTEST_WF` |
| Queue consumers declared by the sibling | 1 |
| Live queues | `loop-ingest` 4 producers / 1 consumer; `loop-ingest-dlq` 0 / 1; `loop-tasks` 3 / 1 |
| Last 1,000 `sibling.cron` rows | `2026-07-25T06:23:07-07:00` through `2026-07-25T23:02:01-07:00` |
| Minute slots inclusive | 1,000 expected; 1,000 observed |
| Inter-arrival gaps | 990 exactly 60 seconds; range 54–63 seconds |
| Within the first seven seconds of the UTC minute | 996 of 1,000; worst second `:10` |
| D1 read receipt | 1,000 rows read in 2.8599 ms |

Reproduce the repository counts:

```sh
rg -n 'waitUntil\(' functions workers/sibling/src --glob '*.js' | wc -l
rg -l 'waitUntil\(' functions workers/sibling/src --glob '*.js' | wc -l
rg -n '^crons|queues\.consumers|\[\[workflows\]\]' wrangler.toml workers/*/wrangler.toml
npx wrangler queues list
```

Reproduce the live cron sample after reading the schema:

```sh
npx wrangler d1 execute loop-content-spine --remote --json \
  --command "PRAGMA table_info(log)"

npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT id, ts FROM log WHERE key='sibling.cron' ORDER BY id DESC LIMIT 1000"
```

The fresh window does not prove that Cron never misses. It proves the narrower statement: these 1,000 consecutive ledger rows covered exactly 1,000 inclusive minute slots, with all arrivals between second `:00` and `:10`. The earlier 1,300-row window remains above as a separate dated receipt.

## Sources

1. Workers Context API — https://developers.cloudflare.com/workers/runtime-apis/context/
2. Queues delivery guarantees — https://developers.cloudflare.com/queues/reference/delivery-guarantees/
3. Queue batching and retries — https://developers.cloudflare.com/queues/configuration/batching-retries/
4. Dead-letter queues — https://developers.cloudflare.com/queues/configuration/dead-letter-queues/
5. Queues limits — https://developers.cloudflare.com/queues/platform/limits/
6. Queues pricing — https://developers.cloudflare.com/queues/platform/pricing/
7. Rules of Workflows — https://developers.cloudflare.com/workflows/build/rules-of-workflows/
8. Sleeping and retrying Workflows — https://developers.cloudflare.com/workflows/build/sleeping-and-retrying/
9. Workflows pricing — https://developers.cloudflare.com/workflows/reference/pricing/
10. Cron Triggers — https://developers.cloudflare.com/workers/configuration/cron-triggers/
11. Durable Object alarms — https://developers.cloudflare.com/durable-objects/api/alarms/
12. Cloudflare workers-sdk — https://github.com/cloudflare/workers-sdk
13. Working with Cloudflare Workflows — https://thisisacomputer.com/articles/cloudflare-workflows
14. Wrangler Workflows commands — https://developers.cloudflare.com/workers/wrangler/commands/workflows/
15. Queues: add API to immediately dead-letter a specific message — https://github.com/cloudflare/workers-sdk/issues/13816
16. Queue producer binding causes 500 errors on all routes when using `wrangler dev --remote` — https://github.com/cloudflare/workers-sdk/issues/9642
17. Unable to test any binding that requires `--remote` with Queues — https://github.com/cloudflare/workers-sdk/issues/5543
18. OAuth for all — https://news.ycombinator.com/item?id=48679522
19. Building durable workflows on Postgres — https://news.ycombinator.com/item?id=48315400
20. What would a Kubernetes 2.0 look like — https://news.ycombinator.com/item?id=44335222
21. Workflow step Uint8Array triggers SQLITE_TOOBIG locally — https://github.com/cloudflare/workers-sdk/issues/14101
22. Fresh first-party async surface inventory — https://miscsubjects.com/api/articles/cloudflare-os-async
23. Fresh first-party queue inventory — https://miscsubjects.com/api/articles/cloudflare-os-async
24. Fresh first-party 1,000-tick cron receipt — https://miscsubjects.com/api/articles/cloudflare-os-async
25. Fresh first-party cron jitter receipt — https://miscsubjects.com/api/articles/cloudflare-os-async
26. First-party queue handoff timing receipt — https://miscsubjects.com/api/articles/cloudflare-os-async


---

# One missing alarm guard turned a $5.75 workload into $34,895

slug: cloudflare-os-workers · https://miscsubjects.com/a/cloudflare-os-workers · tags: cloudflare, architecture, durable-objects, cloudflare-os · updated 2026-07-26T03:59:33.339Z

Most of a Cloudflare build is one Pages deployment answering one request and forgetting everything between requests. Some jobs cannot be written that way: a schedule with no caller, a counter two clients must not race on, a timer that fires in four hours, a session that remembers what it did last turn. Those need a Worker of their own, and sometimes a Durable Object.

A Durable Object is the expensive answer. It is also the one that produced a $34,895 invoice for a founder with zero users. Read the money section before you write the alarm.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Three things can serve a request, and only one of them remembers

| | Pages Function | Standalone Worker | Durable Object |
| --- | --- | --- | --- |
| Who addresses it | a URL path on the Pages project | its own route, `workers.dev` name, or a service binding | a Worker holds a stub obtained from an id; it has no public address |
| Holds state | no | no | yes: private SQLite storage, plus in-memory state while awake |
| Survives the request | no | no, unless woken by cron, a queue, or email | yes; stays in memory until idle, hibernates, reconstructed on next request |
| How many run at once | as many as there is traffic | as many as there is traffic | exactly one per id, worldwide, single-threaded |
| Billed as | Workers requests + CPU time | Workers requests + CPU time | its own line: requests, wall-clock duration at 128 MB, per-row storage |
| Woken by | an HTTP request | HTTP, `scheduled`, `queue`, `email` | a request from a Worker, or its own alarm |

Rows three and four decide it. If two callers must not interleave on the same piece of state, you need something that exists exactly once and runs one thing at a time. That is a Durable Object, and nothing else on the platform is that.

Storage alone is not a reason. [D1 and KV](/a/cloudflare-os-d1) already store things and cost less to operate. Scheduling alone is not a reason: a cron trigger on a plain Worker is cheaper, and [queues, workflows and cron](/a/cloudflare-os-async) covers which of those three fits.

[[embed:source:s15]]

## A Durable Object is one addressable single-threaded instance, and D1 is one of them

Cloudflare's concepts page: "Each Durable Object has a globally-unique name, which allows you to send requests to a specific object from anywhere in the world," and "Durable Objects are single-threaded and cooperatively multi-tasked, just like code running in a web browser."

[[embed:source:s1]]

Precisely, in the order the pieces matter:

1. **A namespace** is a class you exported and declared in your Wrangler config. `DirectoryDO` is a namespace.
2. **An id** picks one instance inside it. `env.DIRECTORY_DO.idFromName('main')` derives the same id from the same string every time, anywhere on earth.
3. **The instance** for that id exists exactly once. Requests queue; they do not run concurrently.
4. **Its storage** is private to that id. Nothing else reads it except by asking that instance.
5. **Its location** is fixed near wherever it was first created, and does not move.

Point 5 is the cost nobody plans for. A Durable Object is not at the edge the way a Worker is. The community tracker at where.durableobjects.live, which continuously creates and destroys objects to sample placement, reported Durable Objects available in **10.8% of Cloudflare points of presence** on the day this page was measured. Your Worker runs next to the reader; the object it talks to may not.

[[embed:source:s17]]

[[embed:source:s18]]

The Workers architect is blunter than the documentation about what a Durable Object is relative to D1:

> I'll let you in on a sort of dirty secret: It's almost always better to use Durable Objects storage, rather than D1. Even if you only want a single global database, it's better to implement that as a singleton Durable Object, than by using D1. Because that's all D1 itself actually is: a singleton Durable Object that exposes an API to its SQLite database. It's just a wrapper.

[[embed:source:s7]]

Follow the reasoning, not the authority. The argument is about round trips: with raw Durable Objects your query code runs on the same machine as the SQLite file, so a chain of queries is local. With D1 the Worker crosses the long-haul network per hop. One query per request and the two are equivalent. Two or more in series and the Durable Object wins by however many round trips it removes.

He grants D1 one advantage, and it is real: "D1's read replica support still isn't exposed in a way that you can use it in raw Durable Objects, so if you are using that, it's a legitimate advantage to D1."

So: read-heavy, globally distributed reads of the same data, no serialisation requirement means D1 with replicas. Write-serialised, per-entity, chained queries mean Durable Object. That is the whole split.

The counterweight, from a reply on the same thread, is that the advice is not reaching the tools people build with: "Pages were slow due to the multiple round trips to storage on each page since Claude Code used D1. Despite repeated prompting Claude Code had no suggestions for how to improve within the CF platform."

[[embed:source:s8]]

## $34,895 with zero users: an alarm that rescheduled itself on every wake-up

The most useful thing on this page. A pre-launch solo founder published the whole postmortem in April 2026.

> My DO agent's onStart() handler called this.ctx.storage.setAlarm() on every wake-up without checking whether an alarm was already scheduled.

[[embed:source:s9]]

The mechanism, step by step:

1. `onStart()` runs every time the object wakes, including after hibernation and including after the alarm handler woke it. The constructor runs *before* the alarm handler, so an unconditional `setAlarm()` in startup code re-arms on every tick.
2. Each preview deployment gets its own Durable Object instances. Sixty-plus previews meant sixty-plus independent copies of the loop, none of them on the production dashboard the founder was watching.
3. The loop peaked at roughly **930 billion row reads per day** on 4–5 April.
4. It ran 3 April to 11 April before it was found. The invoice was **$34,895**, due 15 April, with zero users.
5. Nothing warned: "Cloudflare's Workers Usage Notifications only monitors CPU time. Not Durable Object row reads or writes. There is also no hard spending cap for DO operations available in the dashboard or Wrangler config."

The published fix, verbatim from the post:

```js
// Before (dangerous)
async onStart() {
  await this.ctx.storage.setAlarm(Date.now() + 60_000)
}

// After (safe)
async onStart() {
  const existing = await this.ctx.storage.getAlarm()
  if (!existing) {
    await this.ctx.storage.setAlarm(Date.now() + 60_000)
  }
}
```

Cloudflare documents the trap in a callout most people never reach: "If you wish to call setAlarm inside the constructor of a Durable Object, ensure that you are first checking whether an alarm has already been set. This is due to the fact that, if the Durable Object wakes up after being inactive, the constructor is invoked before the alarm handler."

[[embed:source:s4]]

Four rules follow, in the order to apply them:

1. **Never call `setAlarm()` without reading `getAlarm()` first**, anywhere that can run more than once: constructor, `onStart`, `blockConcurrencyWhile`. All of them run on every wake.
2. **Bound the frequency and the number of ticks.** An alarm that re-arms forever is an infinite loop with a billing meter. Give it a step cap and a terminal state.
3. **Strip Durable Object bindings from preview environments**, or accept that every preview is production as far as the meter is concerned. A preview creates real objects with real storage on the real bill.
4. **Put a budget alert on the account, because the platform will not.** The usage notification you already have watches CPU time, not row operations.

## Alarms fail in two documented ways, and both are silent

The API reference states the contract: "Each Durable Object is able to schedule a single alarm at a time by calling setAlarm()," and "The alarm() handler has guaranteed at-least-once execution and will be retried upon failure using exponential backoff, starting at 2 second delays for up to 6 retries." Six retries is the entire budget.

[[embed:source:s2]]

**One: alarms stop after a code reload in local development.** Filed against `cloudflare/workerd`:

> The alarm triggers as expected, but as soon as the code has changes and the worker reloads, then the alarm stops triggerring.

[[embed:source:s10]]

The alarm is still visible via `ctx.storage.getAlarm()`; it simply never fires again until the dev server restarts. Practical consequence: "my alarm stopped" in `wrangler dev` is not evidence of a bug in your code. Restart the dev server before debugging anything.

**Two: one past timestamp in storage deadlocks scheduling forever.** Filed against `opennextjs/opennextjs-cloudflare`:

> If `nextAlarm` is a past timestamp, no new alarm is set, creating a deadlock where `alarm()` never fires and tags accumulate in the database.

[[embed:source:s11]]

The shape is a scheduling guard that reads the stored alarm, sees *a* value, and skips setting a new one. If the previous handler died after storing a timestamp but before clearing it, that stale past value is permanent, and every later call reads it and does nothing. One transient failure buys a permanent silent outage.

A guard that survives both cases has to check not just that an alarm exists but that it is still in the future:

```js
const MIN_INTERVAL_MS = 30_000;

async scheduleNext(delayMs) {
  const runAt = Date.now() + Math.max(delayMs, MIN_INTERVAL_MS);
  const existing = await this.ctx.storage.getAlarm();
  // Non-null is not enough: a past timestamp means nothing is scheduled.
  if (existing !== null && existing > Date.now()) return;
  await this.ctx.storage.setAlarm(runAt);
}

async alarm() {
  try {
    await this.doWork();
  } catch (err) {
    // Six retries is the platform budget. Re-arm inside the handler so a long
    // downstream outage cannot exhaust it and leave the object unscheduled.
    await this.ctx.storage.setAlarm(Date.now() + 60_000);
    throw err;
  }
}
```

The `catch` block is Cloudflare's own recommendation: "it's recommended to catch any exceptions inside your alarm() handler and schedule a new alarm before returning if you want to make sure your alarm handler will be retried indefinitely."

## WebSockets bill for wall-clock time, and the documented fix is a rewrite

A Durable Object is the natural place to terminate WebSockets because one object holds every connection for one room. The billing consequence is stated in the pricing footnotes: "Calling accept() on a WebSocket in an Object will incur duration charges for the entire time the WebSocket is connected."

Duration is charged at 128 MB regardless of actual use. One idle socket held open for a month is 2,592,000 s × 128 MB ÷ 1 GB = 331,776 GB-s, most of the 400,000 GB-s monthly allowance consumed by one connection doing nothing.

The Hibernation WebSocket API exists for this. Cloudflare marks it recommended and describes it as the one that "allows the Durable Object to hibernate without disconnecting clients when idle." Their own worked example: 100 objects × 100 sockets each, one message per minute, costs **$138.65 per month** on plain WebSockets and **$10.00 per month** with hibernation, because the object is billed for the 10 ms per message rather than the whole month.

[[embed:source:s5]]

The gap between the documented fix and the shipped fix is where people get stuck:

> I have a Cloudflare Worker that uses Durable Objects and WebSocket. However, the costs of WebSocket are high, so I decided to implement the Websocket Hibernation API

[[embed:source:s12]]

That poster hit the cost, read the recommendation, and could not get the hibernation code working at all. Both halves are true: hibernation is the right answer, and it is a rewrite rather than a flag. `acceptWebSocket()` replaces `accept()`, event listeners become `webSocketMessage` / `webSocketClose` / `webSocketError` methods on the class, and per-connection state must move into `serializeAttachment()` because the object is rebuilt from its constructor after every hibernation.

## What it costs, at today's published rates

| Line | Free plan | Paid plan | Notes |
| --- | --- | --- | --- |
| Durable Object requests | 100,000 / day | 1 million / month, then **$0.15 / million** | HTTP requests, RPC sessions, WebSocket messages and **alarm invocations** all count |
| Durable Object duration | 13,000 GB-s / day | 400,000 GB-s / month, then **$12.50 / million GB-s** | Wall clock while active or ineligible for hibernation, billed at 128 MB whatever you use |
| SQLite rows read | 5 million / day | first 25 billion / month, then **$0.001 / million** | The line the $34,895 invoice ran up |
| SQLite rows written | 100,000 / day | first 50 million / month, then **$1.00 / million** | A thousand times the read rate. Each `setAlarm` is a write |
| SQLite stored data | 5 GB total | 5 GB-month, then **$0.20 / GB-month** | An empty SQLite database is about 12 KB |
| Incoming WebSocket messages | — | billed **20:1** as requests | 100 incoming messages bill as 5 requests |
| Plain Worker requests | 100,000 / day | 10 million / month, then **$0.30 / million** | Separate from Durable Object requests |
| Plain Worker CPU time | 10 ms / invocation | 30 million CPU-ms / month, then **$0.02 / million CPU-ms** | Time waiting on I/O is not billed |
| Account minimum | — | **$5 / month** | Applies whatever the usage |

[[embed:source:s3]]

[[embed:source:s6]]

Arithmetic for a stated workload: one Durable Object per user session, 10,000 sessions a day, 20 requests each, 200 ms of active wall clock per request, three row reads and one row write per request:

- Requests: 10,000 × 20 × 30 = 6,000,000 / month. (6,000,000 − 1,000,000) × $0.15 ÷ 1,000,000 = **$0.75**
- Duration: 6,000,000 × 0.2 s = 1,200,000 s × 128 MB ÷ 1 GB = 153,600 GB-s, under the 400,000 allowance = **$0.00**
- Rows read: 18,000,000 / month against 25 billion included = **$0.00**
- Rows written: 6,000,000 / month against 50 million included = **$0.00**
- Account minimum: **$5.00**
- **Total: $5.75 / month.**

Now the same rates against the runaway. 930 billion row reads in one day, priced past the monthly allowance at $0.001 per million, is **$930 for that day's reads alone**. The published invoice was $34,895 over eight days and the postmortem does not break out writes. Writes cost $1.00 per million, a thousand times the read rate, and every `setAlarm` is a write. A loop that writes as well as reads reaches five figures in days. The distance between $5.75 and $34,895 is one missing `getAlarm()`.

## The case for and against, from people running them

The strongest positive is scale with a cost claim attached:

> We serve multi million MAU on sqlite orchestrated through durable objects. It's not the most complex thing in the world but it goes further than CRUD. It costs us such a small amount of money for what it does.

[[embed:source:s13]]

The same commenter says a Postgres cluster was the expensive thing this replaced. Note what makes it work: many small objects, each holding one tenant's data, none holding a socket open. The bill is dominated by requests, and requests are $0.15 per million.

The second positive comes with a boundary the author draws himself, which is the more useful part:

> DO alarms handle the time-based stuff (fleet arrivals, combat resolution, resource ticks) so there's no persistent connection cost. so far costs have been negligible

[[embed:source:s14]]

And immediately after, unprompted: "websockets + stateful server would be the right call for anything realtime. for tick-based strategy with hour-long timers, DOs feel like the cleanest fit."

That is the honest rule. Alarms are cheap because the object sleeps between them. WebSockets are expensive because the object cannot sleep. A game whose actions resolve over hours pays almost nothing; the same game in real time pays duration for every connected second.

Against, at the same scale: the billing blast radius has no ceiling. No hard spending cap for Durable Object operations exists in the dashboard or in Wrangler, the usage notification watches CPU rather than rows, and previews are indistinguishable from production on the meter. Both things hold at once. Choose Durable Objects for what they are good at, and put your own kill switch on the account, because the platform does not ship one.

## Seven Workers sit outside the main deployment, each for a stated reason

This application runs one Pages project with 387 handlers, covered in [Functions as the request layer](/a/cloudflare-os-functions), plus seven Wrangler configurations for standalone Workers.

| Worker | Config | Why it cannot be a Pages Function |
| --- | --- | --- |
| `loop-safe-sibling` | `workers/sibling/wrangler.toml` | Cron triggers `*/1 * * * *` and `0 4 * * *`, a queue consumer on `loop-tasks`, an `email` handler, two Workflow classes and two Durable Object classes. A Pages project has no timer, no queue consumer and no inbound email handler |
| `loop-safe-directory-do` | `workers/directory-do/wrangler.toml` | Hosts the `DirectoryDO` class. Durable Object classes must live in a Worker script; Pages binds to them by `script_name` and cannot define them |
| `loop-safe-storage` | `workers/storage/wrangler.toml` | `workers_dev = false`, reachable only through the `STORE` service binding. Keeps bulk R2 traffic and its D1 index off the request path and off the public surface |
| `miscsubjects-mcp` | `workers/mcp-server/wrangler.jsonc` | A different protocol for a different kind of client, with its own `MiscsubjectsMCP` Durable Object per session, versioned separately from the site |
| `loop-meta-bridge` | `workers/meta-bridge/wrangler.toml` | `workers_dev = false`, no public route. Binds three vendor secrets from Secrets Store *by reference*, so no copy of the token exists in the Pages project |
| `oip-peer` | `workers/oip-peer/wrangler.toml` | The second federation node. A separate registrable domain is the point; a peer boundary that shares a deployment is not a peer boundary |
| `miscsubjects-robots` | `workers/robots-fix/wrangler.toml` | One route, `miscsubjects.com/robots.txt`, one file. No reason to redeploy 387 handlers to change one text file |

Four Durable Object classes are declared across those configs. Counted directly:

```
$ grep -rn "^export class" workers/*/src/index.*
workers/directory-do/src/index.js:14:export class DirectoryDO {
workers/mcp-server/src/index.ts:15:export class MiscsubjectsMCP extends McpAgent<Env> {
workers/sibling/src/index.js:35:export class DeliverWorkflow extends WorkflowEntrypoint {
workers/sibling/src/index.js:65:export class SelfTestWorkflow extends WorkflowEntrypoint {
workers/sibling/src/index.js:114:export class ExpertDO {
workers/sibling/src/index.js:139:export class AgentDO {
```

[[embed:source:s19]]

## The binding-order failure: a deploy that errors on a binding to a script never uploaded

A Durable Object binding in a Pages project names another Worker by script name:

```toml
[[durable_objects.bindings]]
name = "DIRECTORY_DO"
class_name = "DirectoryDO"
script_name = "loop-safe-directory-do"
```

**Symptom.** The Pages deploy fails at the binding step, or succeeds and then every request touching the binding returns a 500. It reads like a malformed configuration file. The TOML is correct.

**Cause.** `script_name` is a *reference* to a Worker that must already exist on the account. Deploy Pages first and there is nothing for the binding to point at. Same for `[[services]]`: this project binds `STORE` to `loop-safe-storage` and `META_BRIDGE` to `loop-meta-bridge`, both references, not definitions.

**Fix.** A fixed deploy order, recorded in the config file itself so nobody has to remember it:

```
# 1. every referenced Worker first
cd workers/directory-do && npx wrangler deploy
cd ../storage           && npx wrangler deploy
cd ../meta-bridge       && npx wrangler deploy
# 2. schema, if the deploy needs it
npx wrangler d1 execute loop-content-spine --remote --file=migrations/<file>.sql
# 3. the Pages project last
npx wrangler pages deploy public
```

The handler in front of the binding names the failure instead of throwing a generic 500, which turns a lost afternoon into a ten-second diagnosis. See `functions/api/durable/[[path]].js`, lines 28–32:

```js
if (!env.DIRECTORY_DO) {
  return new Response(JSON.stringify({ ok: false, error: 'DIRECTORY_DO binding missing — deploy loop-safe-directory-do and add the Pages binding' }), {
    status: 500, headers: { 'content-type': 'application/json' },
  });
}
```

Do the same for every binding you take. Three lines that name the missing Worker pay for themselves the first time.

There is a quieter version of the same class of bug: two copies of one binding drifting apart. This build had one vendor token bound by reference in `loop-meta-bridge` and a second copy held as a Pages environment variable. The bridge copy stayed fresh; the Pages copy expired, and everything reading the Pages copy failed while everything reading the bridge worked. Bind by reference from one place, and keep no second copy.

## What a real Durable Object in this build does, read from the source

`workers/directory-do/src/index.js` is 102 lines and shows the whole shape of a minimal Durable Object.

**Lines 14–27: schema on construction.** The class takes `state` and `env`, grabs `state.storage.sql`, and wraps its `CREATE TABLE IF NOT EXISTS` calls in `state.blockConcurrencyWhile()`. That wrapper is the safety: no request is served until the callback resolves, so no handler can see a half-built schema. Two tables exist: `slugs`, a registry of declared internal addresses, and `intents`, an append-only log of every mutation.

**Lines 54–66: a write that is safe because there is only one writer.** `slug.register` reads the existing row to preserve its original `declared_at`, writes with `INSERT OR REPLACE`, then appends to `intents`. Those reads and writes cannot interleave, because exactly one instance exists for the id `main` and it is single-threaded. Written against D1 the same sequence is a read-modify-write race needing a transaction or a version column.

**Lines 85–95: how a caller reaches it.** `env.DIRECTORY_DO.idFromName('main')` derives the id, `.get(id)` returns a stub, `stub.fetch()` sends a request. The URL passed to the stub is a fabricated `https://do/`; the hostname is meaningless, only path and query reach the object.

[[embed:source:s16]]

Contrast `AgentDO` in `workers/sibling/src/index.js`, lines 139–200: an alarm-driven loop, the risky shape. It survives the $34,895 failure mode for four nameable reasons.

- `setAlarm()` is called in `spawn` (once per agent), in `send` / `resume` only when the status is not already `running`, and at the end of `alarm()`, never in the constructor.
- `alarm()` returns immediately if `status !== 'running'`, so a killed or completed agent stops re-arming.
- `maxSteps` is clamped to at most 40 with `Math.min(Math.max(parseInt(b.maxSteps || '12', 10) || 12, 1), 40)`, and `alarm()` sets `status = 'done'` once `steps >= maxSteps`. The loop is bounded by construction.
- `kill` calls `this.state.storage.deleteAlarm()`.

That is what "bound the alarm" means in code. It is still not fully defended: a `setAlarm` added to the constructor tomorrow reintroduces the bug. That is why the `getAlarm()` guard above belongs in any new class.

## Measured here: the Durable Object hop is not the latency you think it is

Three first-party measurements, with the commands, so they can be rerun.

**1: every Worker on the account and when it last shipped.** From the repository root, wrangler 4.103.0:

```
$ for w in loop-safe-sibling loop-safe-directory-do loop-safe-storage \
           miscsubjects-mcp miscsubjects-robots loop-meta-bridge oip-peer; do
    printf "%-28s " "$w"
    npx wrangler deployments list --name "$w" | grep -m1 "^Created:"
  done
```

| Worker | Latest deployment created |
| --- | --- |
| `loop-safe-sibling` | 2026-07-03T03:32:24Z |
| `loop-safe-directory-do` | 2026-06-13T23:24:17Z |
| `loop-safe-storage` | 2026-06-16T18:59:19Z |
| `miscsubjects-mcp` | 2026-06-20T19:19:34Z |
| `miscsubjects-robots` | 2026-07-01T08:30:03Z |
| `loop-meta-bridge` | 2026-07-12T03:13:16Z |
| `oip-peer` | 2026-07-15T20:44:30Z |

The Durable Object host has not been redeployed since June and does not need to be — a bound Durable Object Worker changes only when its class changes.

**2 — round-trip latency, and a measurement error corrected in public.** Ten sequential requests to `/robots.txt` (a standalone Worker, no bindings) gave a 164 ms median; ten to `/api/durable/ping` (a Pages Function calling a Durable Object stub) gave 643 ms. That looks like a 4x penalty for the Durable Object hop. It is not. The two blocks ran minutes apart and the difference is client network drift. Rerun interleaved — one request to each per iteration, twelve iterations — and it disappears:

```
$ for i in $(seq 1 12); do
    a=$(curl -s -o /dev/null -w "%{time_total}" https://miscsubjects.com/robots.txt)
    b=$(curl -s -o /dev/null -w "%{time_total}" https://miscsubjects.com/api/map)
    c=$(curl -s -o /dev/null -w "%{time_total}" https://miscsubjects.com/api/durable/ping)
    echo "$a $b $c"
  done
```

| Endpoint | n | min | median | p90 | max |
| --- | --- | --- | --- | --- | --- |
| `/robots.txt` — standalone Worker, no bindings | 12 | 108 ms | 272 ms | 673 ms | 1294 ms |
| `/api/map` — Pages Function, no Durable Object | 12 | 159 ms | 237 ms | 585 ms | 1301 ms |
| `/api/durable/ping` — Pages Function → Durable Object | 12 | 154 ms | 276 ms | 381 ms | 748 ms |

The three are indistinguishable at this sample size, and the Durable Object path has the *tightest* tail. Honest conclusion: on this deployment, from this client, the Durable Object hop is buried inside ordinary network variance. The method matters more than the number — measure interleaved, or publish your own jitter as a platform finding.

**3 — the object's real state, read live.** The Pages front door at `/api/durable/*` forwards to the stub, so a plain GET reads what the object holds:

```
$ curl -s https://miscsubjects.com/api/durable/ping
{"ok":true,"do":"DirectoryDO","id":"61f9320db3f158babd018d01b56ca7db4434be41d738fc4dbc294ef21d45d883","ts":"2026-07-26T04:40:18.284Z"}

$ curl -s https://miscsubjects.com/api/durable/slug.list | python3 -c "import json,sys; print(json.load(sys.stdin)['count'])"
54
```

Fifty-four slugs in the registry; the `intents` log returns 157 rows against its `LIMIT 200`. The `id` is the 64-hex object id derived from the name `main` — the same string every time, from anywhere, which is the addressing property the whole design rests on.

[[embed:source:s20]]

## Which one to reach for

| The job | Choose | Why |
| --- | --- | --- |
| Answer an HTTP request for the site | Pages Function | Already deployed with the site, shares its bindings, no extra address to maintain |
| Run something on a timer | standalone Worker with a cron trigger | A Pages project has no timer, and nothing about a schedule needs state |
| Drain a queue | standalone Worker with a queue consumer | Pages projects can produce to a queue but cannot consume from one |
| Serve one endpoint that changes on a different cadence than the site | standalone Worker on a route | A deploy boundary is a blast-radius boundary |
| Serialise writes to one entity — a counter, a room, a document | Durable Object | The only thing on the platform that exists exactly once and runs one thing at a time |
| Hold a session's working memory across many calls | Durable Object | In-memory state survives between requests; storage survives hibernation |
| Chain three or more queries for one request | Durable Object with SQLite storage | Query code runs on the same machine as the file, so the chain is local |
| Serve the same read-heavy data globally | D1 with read replicas | The one advantage the architect grants D1 over raw Durable Objects |
| Real-time bidirectional messaging | Durable Object with the **Hibernation** WebSocket API | Duration billing on a plain `accept()` socket is the most expensive mistake available |
| A long multi-step job that must survive failure | a Workflow, not a Durable Object | Covered in [queues, workflows and cron](/a/cloudflare-os-async) |

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| Pages deploy errors on a binding, or every request touching it 500s | `script_name` / `service` points at a Worker not yet uploaded | Deploy the referenced Workers first, Pages last. Add an `if (!env.BINDING)` branch that says so |
| `{"ok":false,"error":"DIRECTORY_DO binding missing — deploy loop-safe-directory-do and add the Pages binding"}` | The Durable Object host Worker is absent from the account or the environment | `cd workers/directory-do && npx wrangler deploy`, then redeploy Pages |
| Row reads climb with no traffic | `setAlarm()` called unconditionally somewhere that runs on every wake | Guard with `getAlarm()`, and verify the stored value is in the future, not merely non-null |
| The bill is large and the production dashboard looks quiet | Preview deployments created their own Durable Object instances | Strip Durable Object bindings from preview environments, or count previews as production |
| A background job silently stopped and never restarts | A failed handler left a past timestamp; the scheduling guard reads it as "already scheduled" | Treat `existing <= Date.now()` as unscheduled and set a new alarm |
| Alarm fires once in `wrangler dev`, then never again after an edit | Hot reload drops the alarm while `getAlarm()` still reports it — `workerd` issue 3566 | Restart the dev server. Do not debug your code first |
| Alarm stops after roughly six failures | Retry budget exhausted — six retries, exponential backoff from 2 s | Catch inside `alarm()`, set a new alarm, then rethrow |
| WebSocket bill dominated by duration, not messages | `accept()` keeps the object in memory for the whole connection | Move to `acceptWebSocket()` plus `webSocketMessage` / `webSocketClose` handlers and `serializeAttachment()` |
| A Durable Object stays billed with no requests arriving | An outbound `connect()` or WebSocket holds it in memory for up to 15 minutes per connection | Close outbound connections when the work is done |
| Two copies of one secret, one expired | A binding duplicated as an environment variable instead of referenced from one place | Bind by reference from a single Worker and service-bind to it |

Every binding this build declares, and what each costs, is on the [Cloudflare OS index](/a/cloudflare-os).


## Sources

1. What are Durable Objects? — https://developers.cloudflare.com/durable-objects/concepts/what-are-durable-objects/
2. Durable Object lifecycle — https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/
3. Durable Objects pricing — https://developers.cloudflare.com/durable-objects/platform/pricing/
4. Durable Object alarms API — https://developers.cloudflare.com/durable-objects/api/alarms/
5. Use WebSockets with Durable Objects — https://developers.cloudflare.com/durable-objects/best-practices/websockets/
6. Cloudflare Workers pricing — https://developers.cloudflare.com/workers/platform/pricing/
7. Temporary Cloudflare accounts for AI agents — https://news.ycombinator.com/item?id=48611834
8. Temporary Cloudflare accounts for AI agents — https://news.ycombinator.com/item?id=48611834
9. Durable Object alarm loop: $34k in 8 days, zero users, no platform warning — https://news.ycombinator.com/item?id=47787042
10. 🐛 BUG: Durable Object Alarms not triggering after a code reload — https://github.com/cloudflare/workerd/issues/3566
11. [BUG] Durable Objects alarm not firing due to stale past alarms remaining in storage — https://github.com/opennextjs/opennextjs-cloudflare/issues/929
12. Trying to use Websocket Hibernation Api — https://stackoverflow.com/questions/79336461/trying-to-use-websocket-hibernation-api
13. SQLite Is All You Need — https://news.ycombinator.com/item?id=48946048
14. Show HN: I rebuilt a 2000s browser strategy game on Cloudflare's edge — https://news.ycombinator.com/item?id=47785298
15. Cron Triggers — https://developers.cloudflare.com/workers/configuration/cron-triggers/
16. Access Durable Object storage — https://developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-storage/
17. Where Durable Objects Live — https://where.durableobjects.live/
18. Durable Objects: Easy, Fast, Correct — Choose three — https://blog.cloudflare.com/durable-objects-easy-fast-correct-choose-three/
19. Production Worker and Durable Object inventory — https://miscsubjects.com/api/durable/slug.list
20. Live DirectoryDO response — https://miscsubjects.com/api/durable/ping


---

# Pages Functions compiles 224 route files into one 1.35 MB Worker

slug: cloudflare-os-functions · https://miscsubjects.com/a/cloudflare-os-functions · tags: cloudflare, architecture, workers, cloudflare-os, pages-functions, routing, middleware, wrangler, deployment · updated 2026-07-26T03:59:30.563Z

A Pages Function is a JavaScript file inside a directory called `functions/`. Cloudflare compiles every one of those files into a single Worker script and runs it at the edge in front of the site's static files. There is no route table to write: the path of the file *is* the URL it answers.

That is fine at ten files. This application has 387 modules under `functions/`, of which 223 are route files exporting 270 request handlers. At that size four things start to matter: which file wins when two could match, whether a request touches the Worker at all, how big the compiled bundle has grown against a hard ceiling, and what you can see when a handler fails.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## The file tree is the router, and there is no other route table

Cloudflare's routing document states it in one line: your `/functions` directory structure determines the routes. Real paths from this repository:

| File | URL it answers |
| --- | --- |
| `functions/index.js` | `/` |
| `functions/latest.js` | `/latest` |
| `functions/a/[slug].js` | `/a/cloudflare-os-functions`, `/a/anything` |
| `functions/api/articles/[[path]].js` | `/api/articles` and every path beneath it |
| `functions/api/articles/design-law/skill.js` | `/api/articles/design-law/skill` |
| `functions/_middleware.js` | every request, before any of the above |

Three bracket forms, and they behave differently:

| Form | Captures | `context.params` type | Example in this repo |
| --- | --- | --- | --- |
| `name.js` | exactly that path | — | `functions/latest.js` |
| `[param].js` | exactly one path segment | string | `functions/a/[slug].js` |
| `[[path]].js` | one segment or many | array of strings | `functions/api/articles/[[path]].js` |

`functions/a/[slug].js` answers `/a/thing` and **not** `/a/thing/extra`. The catch-all answers `/api/articles/x/y/z` and receives `context.params.path` as `["x","y","z"]`. If no Function matches, Pages falls through to a static file with that name. Trailing slashes are ignored.

## A handler file exports up to eight functions, one per HTTP method

The API reference is explicit about the interaction between them: `onRequest` is called *unless* a more specific `onRequestVerb` is exported. Export both `onRequest` and `onRequestGet` and a GET only ever reaches `onRequestGet`. The eight names are `onRequest`, `onRequestGet`, `onRequestPost`, `onRequestPut`, `onRequestPatch`, `onRequestDelete`, `onRequestHead`, `onRequestOptions`. Counted across this repository today:

| Export | Count |
| --- | --- |
| `onRequestGet` | 169 |
| `onRequestPost` | 51 |
| `onRequest` | 28 |
| `onRequestOptions` | 7 |
| `onRequestPut` | 6 |
| `onRequestDelete` | 6 |
| `onRequestPatch` | 3 |
| **Total handler exports** | **270** |

```bash
grep -rhoE '^export (async )?(function|const) onRequest[A-Za-z]*' functions --include='*.js' \
  | grep -oE 'onRequest[A-Za-z]*' | sort | uniq -c | sort -rn
```

Every handler receives one argument, the `EventContext`. Its useful members: `request`, `env` (your bindings), `params` (the bracket captures), `next()` (pass through to the next Function or to the static asset server), `waitUntil()` (finish work after the response is sent) and `passThroughOnException()`.

## One middleware file sits in front of 100 percent of traffic

`_middleware.js` is the only filename Pages treats as middleware. At `functions/_middleware.js` it runs in front of the entire application, static files included. At `functions/users/_middleware.js` it runs only for requests under `/users`. It calls `context.next()` to hand the request onward and can rewrite what comes back. This application has exactly one, 946 lines, exporting a single `onRequest`. Its order is a property of that file, not of Pages:

1. Edge cache lookup for public, cacheable reads.
2. Lean-body branch: a crawler or model fetcher gets the same content from a KV snapshot with the stylesheet stripped, marked private so no shared cache can hand it to a person.
3. `adminGate(context)`, before any handler sees the request.
4. `machineDataGuard`: a browser navigating to a raw `/api/` URL gets a readable page, a machine gets JSON.
5. `?bundle=1` redirects to the object-folder endpoint.
6. `context.next()` into the matched handler.
7. Category header on every response, injections, cache write.

Two things to know before putting this much into middleware. You may export an array instead of a function — `export const onRequest = [errorHandling, authentication]` — and entries run in order, so the first can catch throws from the rest. And CPU spent in middleware is charged against the same per-request budget as the handler, because they are the same Worker invocation.

## An optional catch-all can beat a static file with the same name

Cloudflare's routing document says: *"More specific routes (routes with fewer wildcards) take precedence over less specific routes."* Read plainly, a real filename beats `[[path]].js`. This repository does not trust it. At `functions/api/articles/[[path]].js` line 788:

```js
// Canonical Knowledge-Action subresources share the existing article router.
// Keep this dispatch explicit because the optional catch-all route can outrank a
// same-name static function for `/skill` on Pages.
```

The layout that produces the ambiguity:

```
functions/api/articles/
├── [[path]].js                  ← matches /api/articles/**
└── design-law/
    ├── index.js
    └── skill.js                 ← matches /api/articles/design-law/skill exactly
```

Both can serve `GET /api/articles/design-law/skill`. The static `skill.js` exports `onRequestGet` and returns markdown; the catch-all carries a branch at the top of its `handle()` returning byte-identical markdown for the same path. The response proves nothing about which one ran:

```
$ curl -sI https://miscsubjects.com/api/articles/design-law/skill
HTTP/2 200
content-type: text/markdown; charset=utf-8
content-disposition: inline; filename="SKILL.md"
```

That symmetry is the defence: because both return the same bytes, either can win the route and the site behaves. Delete the branch in the catch-all and you are betting the endpoint on precedence resolving your way.

The rule: **specificity is scored, not guaranteed by having a real filename.** When a catch-all and a static file overlap, either make the catch-all handle the path too, or move the static file out of its subtree.

## `_routes.json` decides whether a request costs anything

Once a `functions/` directory exists, every request invokes the Worker by default, including requests for fonts and images a Function was never going to touch. `_routes.json` takes routes back. It lives in the build output directory — `public/` here, not the repository root — and has three keys: `version`, `include`, `exclude`. Exclude always wins over include. This project's file, complete:

```json
{
  "version": 1,
  "description": "Tell Cloudflare Pages NOT to handle these paths — they belong to the miscsubjects-admin Worker via its route bindings.",
  "include": ["/*"],
  "exclude": [
    "/site.html", "/site", "/showcase", "/showcase.html",
    "/widgets", "/widgets.html", "/font/*", "/control", "/spec",
    "/edit/*", "/article/*", "/condition/*",
    "/import-export", "/import", "/export"
  ]
}
```

One include rule and fifteen exclude rules: sixteen of the hundred allowed. The file's own limits are a trap — at least one include rule is required, no more than 100 rules combined, none longer than 100 characters. A wildcard matches any number of segments, so `/font/*` covers everything below `/font/`.

Two reasons the exclude list matters, and only one is money. Excluding `/font/*` means a font request never enters this Worker's CPU budget and never counts as an invocation; static requests on Pages are free and unlimited, Function invocations bill at the Workers rate. The second is correctness: those paths are claimed by a separate Worker bound to those routes, and leaving them included would mean this Worker answered first.

## `HANDOFF_CLAUDE_CODE.md` says 387 handlers; 213 files actually handle a request

Re-counted today the total is still 387 — but "handlers" flatters it. That figure counts every JavaScript module under `functions/`, and 164 are shared library code that never answers a URL.

```bash
find functions -name '*.js' -not -name '*.test.*' | wc -l                                  # 387
find functions -name '*.js' -not -path 'functions/_lib/*' -not -name '*.test.*' | wc -l    # 223
find functions/_lib -name '*.js' -not -name '*.test.*' | wc -l                             # 164
grep -rlE '^export (async )?(function|const) onRequest' functions --include='*.js' | wc -l # 213
```

| Thing counted | Today |
| --- | --- |
| Modules under `functions/` | 387 |
| Route files (excluding `_lib/`) | 223 |
| Shared library modules in `_lib/` | 164 |
| Route files exporting a handler | 213 |
| Handler exports across those files | 270 |
| `_middleware.js` files | 1 |
| `[param].js` single-segment routes | 19 |
| `[[path]].js` catch-alls | 32 |

Ten route files export no handler — `functions/a/writing-law.js`, `functions/api/design-law.js` and eight others export helpers that neighbouring route files import. They still compile into the bundle. Where the routes live: `functions/api/` 124, `functions/admin/` 42, `functions/` root 26, `functions/a/` 7, `functions/oip/` 3, `functions/content/` and `functions/.well-known/` 2 each, 17 further single-file directories.

```bash
find functions -name '*.js' -not -path 'functions/_lib/*' -not -name '*.test.*' \
  | awk -F/ '{ if (NF==2) print "functions/ (root)"; else print "functions/"$2"/" }' \
  | sort | uniq -c | sort -rn
```

## The limits, with the numbers the documentation carries today

Fetched 25 July 2026 from Cloudflare's Pages limits page (last updated 16 July 2026) and Workers limits page. Pages Functions are Workers, so compute and size come from the Workers page, asset limits from the Pages page.

| Limit | Workers Free | Workers Paid | Source page |
| --- | --- | --- | --- |
| Compiled script size, **after gzip** | 3 MB | 10 MB | Workers limits |
| Compiled script size, before compression | 64 MB | 64 MB | Workers limits |
| Worker startup time | 1 second | 1 second | Workers limits |
| CPU time per request | 10 ms | 5 min (default 30 s) | Workers limits |
| Memory per isolate | 128 MB | 128 MB | Workers limits |
| Subrequests per invocation | 50 | 10,000 | Workers limits |
| Subrequests to internal services | 1,000 | 10,000 default | Workers limits |
| Files per Pages site | 20,000 | 100,000 | Pages limits |
| Size of a single site asset | 25 MiB | 25 MiB | Pages limits |
| Git-integration builds per month | 500 | 5,000 (Pro) | Pages limits |
| `_routes.json` rules, include + exclude | 100 | 100 | Pages routing |
| Custom domains per project | 100 | 250 (Pro) | Pages limits |

Four of these bite differently than the table suggests.

**Only the gzipped number counts.** OpenNext's Cloudflare troubleshooting page is blunt: *"When deploying your Worker, wrangler will show both the original and compressed sizes. Only the latter (gzipped size) matters for these limits."* A 5 MB `index.js` is not automatically a problem.

**CPU time is not wall time.** Waiting on a `fetch()`, a KV read or a D1 query does not count. Cloudflare's own figure: the average Worker uses about 2.2 ms per request. 10 ms free is tight for server-side rendering, generous for a handler that mostly awaits I/O.

**Subrequests count binding calls.** A KV read, an R2 get and a D1 query are each one. A handler looping one D1 query per item hits the free ceiling at 50 items.

**The 500-per-month cap is on Git-integration builds, not deploys.** A Direct Upload — `wrangler pages deploy` from a machine, how this project ships — is not a Pages build, and preview deployments are explicitly unlimited. The current limits page carries no deployments-per-month cap at all.

## `workers.api.error.script_too_large`: three walls, three different exits

That string is the API error returned when the compiled bundle exceeds the plan's size limit. It is the most common way a growing Pages project stops deploying, and it never appears locally, because local development uploads nothing. Three real reports, failing for three different reasons.

**Too many pages compiled into the script.** Jeffh30 on Stack Overflow, 25 September 2023, deploying a SvelteKit blog: *"I now have more than 200 posts with multiple components each. Everything was working well, but recently started getting the following error when deploying to Cloudflare pages"*. No individual post was wrong; the framework compiled every one into `_worker.js`, and around 200 the total crossed the ceiling.

**One binary larger than the whole allowance.** matthewjewell on `nuxt-modules/og-image` issue 193, 13 April 2024: *"Tried deploying a branch with a simple template, it works well locally, but with the 1mb worker limit on Pages (free) I get the `workers.api.error.script_too_large` error as the compiled-wasm file is 2.4mb."* A single 2.4 MB WebAssembly file against a then-1 MB free ceiling. No code-splitting saves that; the asset has to leave the bundle.

**Neither — the upload itself dies.** revmischa on `cloudflare/workers-sdk` issue 1194, 6 June 2022: *"When I upload to pages with wrangler 2 it goes reaaallly slow and then crashes"* — a roughly 300 MB site that uploaded fine under wrangler 1, crawling then failing at 809 of 8009 files. That is the asset pipeline, not the script limit, and it earns a mention because from the terminal it looks identical: a deploy that does not finish.

One correction before copying a fix from any of those threads: **the free ceiling those two hit was 1 MiB, and it is 3 MB today.** Cloudflare raised the Workers Free script limit from 1 MiB to 3 MiB in late November 2024. A project blocked in 2023 may deploy unchanged now.

| Fix | What it does | When it is the right one |
| --- | --- | --- |
| Measure first | `wrangler pages functions build --outdir <dir>`, then gzip the output | Always, before guessing |
| Move the asset out of the bundle | Serve it from R2, KV or as a static Pages asset instead of importing it | A binary — WASM, a font, a model file, a large JSON blob — dominates |
| `import()` instead of a top-level import | Splits the module into a chunk loaded on demand | A heavy dependency is used by a few routes only |
| Delete dependencies | Removes them from the graph entirely | A package was pulled in for one function |
| Upgrade to Workers Paid | 3 MB → 10 MB after compression | The bundle is genuinely that large and every route is needed |
| Move to Workers with static assets | Split across Workers with service bindings | Growth is structural and no single trim will hold |

## Measured: 5.07 MiB of compiled source, 1.29 MiB gzipped

`wrangler pages functions build` runs the same compilation the deploy runs and writes the Worker to a directory instead of uploading it. Take this measurement before believing anything about your headroom.

```bash
cd /path/to/your/pages/project
npx wrangler pages functions build --outdir /tmp/pf-build
#  ✨ Compiled Worker successfully
find /tmp/pf-build -type f
#  /tmp/pf-build/index.js
stat -f "%z" /tmp/pf-build/index.js          # macOS; stat -c %s on Linux
#  5316456
gzip -c /tmp/pf-build/index.js | wc -c
#  1349068
```

Run against this repository on 25 July 2026 with wrangler 4.103.0, all 387 modules compile to **one file**:

| Figure | Bytes | Human |
| --- | --- | --- |
| Compiled, uncompressed | 5,316,456 | 5.07 MiB |
| Compiled, gzipped | 1,349,068 | 1.29 MiB |
| Compression ratio | — | 3.94× |
| Free ceiling, 3 MB gzipped | 3,000,000 | 45.0% consumed |
| Paid ceiling, 10 MB gzipped | 10,000,000 | 13.5% consumed |

Two conclusions. This application sits at 45% of the free ceiling and 13.5% of the paid one — nowhere near the wall. And it would have failed the old 1 MiB free limit by 29%: the same 387 modules could not have been deployed to a free account before November 2024. The 3.94× ratio is why judging by uncompressed size is useless: 5.07 MiB looks alarming against "3 MB" and is under half of it.

## Cold and warm, measured over eighteen requests from one client

Cold-start figures for Workers are usually vendor-quoted. These were taken on 25 July 2026 against a live Pages Function on this site, published with the command so the spread is visible instead of one flattering figure.

```bash
cat > /tmp/fmt.txt <<'EOF'
dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} code=%{http_code}\n
EOF

# New TLS connection each time, cache-busted so the edge cache cannot answer
for i in 1 2 3 4 5; do
  curl -s -o /dev/null -w "@/tmp/fmt.txt" \
    "https://miscsubjects.com/api/articles/cloudflare-os-functions?cb=$RANDOM$i"
done

# Eight requests on one reused connection
curl -s -w "@/tmp/fmt.txt" \
  $(for i in $(seq 8); do echo -n "-o /dev/null https://miscsubjects.com/api/articles/cloudflare-os-functions "; done)
```

| Run | Time to first byte | Total |
| --- | --- | --- |
| New connection, cache-busted, 5 runs | 198 / 280 / 297 / 800 / 810 ms | 248 ms – 1.272 s |
| Same connection reused, 7 runs after the handshake | 103 / 119 / 139 / 145 / 176 / 216 / 286 ms | 104 – 500 ms |
| `/font/Asap-Regular.woff2`, excluded in `_routes.json`, 5 reused | 62 / 62 / 68 / 88 / 122 ms | 64 – 123 ms |

Median time to first byte: **145 ms** for the Function route on a warm connection, **68 ms** for the excluded static route. The Function route ran a D1 query and assembled a JSON document; the font returned `cf-cache-status: HIT`. Not like-for-like, and the gap is not a measurement of routing overhead — but it is the honest size of the difference between a path that enters the Worker and a path `_routes.json` keeps out.

The spread is the finding. The slowest new-connection run took 5.1× the fastest. Any single-number latency claim about Pages Functions from one client is noise.

## A 500 with an empty body, and nothing to look at

Uninen on Hacker News, 24 February 2025: *"a CloudFlare pages function would return 500 + nonsensical error and an empty response in prod. Tried to figure this out all Friday. It was super annoying to fix as there's no way to add more logging"*.

Partly still true.

**Live tailing works.** `wrangler pages deployment tail`, run in the project directory, streams every invocation of the current production deployment as structured JSON — `outcome`, `exceptions` with stack and message, `logs` from your own `console.log`, and the request and response objects. The dashboard shows the same stream. It is live, so it shows a failure only while you are watching and reproducing it.

**Persistent, queryable logs do not.** Cloudflare's own Pages-to-Workers compatibility matrix marks Workers Logs, Logpush, Tail Workers and Source Maps unsupported on Pages and supported on Workers. Real-time logs is the only row supported on both. On Pages you cannot look at what happened an hour ago, and a minified stack trace stays minified because source maps do not upload.

What to do instead, in order:

1. Reproduce with `wrangler pages deployment tail` running. The `exceptions` array carries the message and stack the empty 500 withheld.
2. Wrap middleware in try/catch and return the error. Cloudflare's own middleware example does exactly this — `return new Response(err.message + "\n" + err.stack, { status: 500 })` — turning an empty 500 into a readable one. Gate it behind a header so it is not public.
3. If the Worker throws at module scope, no handler runs and no `console.log` inside one will ever fire. A module-scope throw takes the whole deployment down, which is exactly the "empty response" shape. Check top-level code and imports first.
4. If you need logs you can query after the fact, that is Workers, not Pages.

## Pages or Workers: a verdict for each row

Cloudflare's position, from its own migration guide: *"Unlike Pages, Workers has a distinctly broader set of features available to it, (including Durable Objects, Cron Triggers, and more comprehensive Observability)."* Static asset requests are free on both and Function invocations bill at the same rate, so cost is not the deciding variable. Two blockers stop people who want to follow that advice.

merek on Hacker News, 10 August 2025: *"I had to use Pages since Workers don't support \"Custom domains outside Cloudflare zones\" [1]. There's no way I can transfer the domain since I have subdomains tightly integrated with AWS services."* The compatibility matrix confirms it — custom domains outside Cloudflare zones is the one row marked supported on Pages and unsupported on Workers.

scottydelta, same thread, same day: *"I recently ported an entire TS project from cloudflare workers to a django python app since cloudflare workers don't support choice of region/country when deploying workers."* Placement Hints now bias a Worker toward a named cloud region — `placement.region` set to something like `aws:us-east-1` — but Cloudflare is explicit that Workers run on its network rather than inside cloud regions, so a hint is a latency optimisation. Guaranteed geographic confinement is Regional Services, and the documentation states it is an Enterprise add-on.

| Your situation | Pages | Workers | Verdict |
| --- | --- | --- | --- |
| Nameservers are not Cloudflare's and cannot move | Supported | Not supported | **Stay on Pages.** A hard block, not a preference. |
| You need logs you can query after the incident | Real-time tail only | Workers Logs, Logpush, Tail Workers, source maps | **Move to Workers.** |
| You need a cron schedule in the same project | Not supported | Supported | **Move to Workers,** or keep Pages and put the schedule in a sibling Worker. |
| You need Durable Objects | Only by binding to a separate Worker | Native | **Move to Workers** unless the extra Worker is acceptable. |
| You want file-based routing | Native | Not native | **Stay on Pages,** or adopt a router — Cloudflare's guide names HonoX. |
| Compiled bundle near the ceiling and still growing | One script, one ceiling | Split across Workers with service bindings | **Move to Workers.** |
| You need Queue consumers, Email Workers, Image Resizing or Rate Limiting bindings | Not supported | Supported | **Move to Workers.** |
| You need gradual deployments or the Vite plugin | Not supported | Supported | **Move to Workers.** |
| Static site, a handful of endpoints, Cloudflare DNS | Fine | Fine | **Either.** No reason to migrate. |

What this application would have to change to move: file-based routing across 223 route files becomes an explicit router — the 32 catch-alls map to prefix routes, the 19 single-segment files to path patterns. `_routes.json` has no Workers equivalent and becomes route configuration plus the static-asset binding. `public/` becomes an assets binding. In exchange: Workers Logs, source maps, cron triggers, and the ability to split when the bundle grows. Nothing in that list is hard. Nothing in it is urgent at 45% of the free ceiling.

## `✨ Uploading Functions bundle` is the line that proves the deploy shipped code

```bash
cd /path/to/your/pages/project     # NOT optional — see below
npx wrangler pages deploy public --project-name <your-project> --branch main
```

`public` is the build output directory. Wrangler uploads the files in it, compiles `functions/` from the *current working directory* into one Worker, and uploads `_routes.json` from the output directory. A real successful deploy of this project printed:

```
✨ Compiled Worker successfully
Uploading... (4/4)
✨ Success! Uploaded 0 files (4 already uploaded) (0.61 sec)
✨ Uploading Functions bundle
✨ Uploading _routes.json
🌎 Deploying...
✨ Deployment complete!
```

The absence of the Functions-bundle line is the failure mode. Wrangler resolves `functions/` relative to the shell's working directory, not relative to the output-directory argument. Run the same command from one directory up and it uploads the static files perfectly, finds no `functions/`, ships a Functions-less deployment, and every dynamic route starts returning 404 or 405 — a full production outage from a command that printed no error. Redeploy from the project directory. Nothing else fixes it, because the deployment that shipped genuinely contains no Worker.

## What the ship script refuses to do

`scripts/ship.mjs` wraps that same command in gates. Each gate corresponds to a way a deploy has already gone wrong.

| Gate | What it checks | The failure it prevents |
| --- | --- | --- |
| `verifyProductionLineage` | `git rev-parse HEAD` equals `git rev-parse origin/main`; no uncommitted runtime files; `scripts/check-protected-features.mjs` passes | Shipping local-only code nobody can reproduce or roll back to |
| `verifyProtocolLawClosure` | Every law marked deployed has a unique conformance clause present in `functions/_lib/oip_conformance.js` | A rule declared live with no code enforcing it |
| `reportStrandedWork` | Lists saved branches with commits not in `main`; informational, never blocks | Silent loss of work that never rejoined the live line |
| Deploy lease | A KV key with an 1800-second TTL and a nonce, re-read after writing to confirm ownership, with an acquire receipt in the events database | Two machines deploying at once |
| Preview-first promotion | Deploys to a preview alias, smoke-tests `/design` there with up to 12 retries at 10-second intervals, and only then deploys to `main` | A render-time throw reaching production |
| Production smoke | Re-tests five real paths against the live host with up to 5 retries, failing if a body is under 1500 bytes or matches `/render error\|internal server error\|cannot read\|referenceerror\|is not defined\|1101 \|worker threw/i` | A deploy that succeeded and a site that is broken |

The preview-first step carries a subtlety that generalises to any Pages project with a database. Preview deployments bind to a separate, empty preview database, so any page whose first act is a populated query returns 500 on preview while being perfectly healthy in production. The script splits the smoke sets accordingly: the preview set holds only pages that render from code, the production set holds the data pages. A module-scope error takes the entire Worker down, so it still surfaces on preview even though the data pages cannot be checked there.

## Symptom, cause, fix

| Symptom | Real error string | Cause | Fix |
| --- | --- | --- | --- |
| Deploy rejected, no upload | `workers.api.error.script_too_large` | Compiled bundle over 3 MB gzipped (free) or 10 MB (paid) | Measure with `wrangler pages functions build --outdir` then gzip; move binaries to R2 or KV; dynamic-import heavy modules; upgrade the plan |
| Deploy succeeded, every `/api/*` returns 404 or 405 | none — the deploy printed no error | `wrangler pages deploy` ran outside the project directory, so `functions/` was never compiled | Confirm `✨ Uploading Functions bundle` in the output; redeploy from the project directory |
| 500 with an empty body, production only | none in the response | An uncaught throw, often at module scope, so no handler ran | Reproduce with `wrangler pages deployment tail` and read the `exceptions` array; check top-level code and imports first |
| Request reaches the wrong file | none | A `[[path]].js` catch-all in an ancestor directory matched before the specific file | Handle the path in the catch-all too, or move the specific file out of its subtree |
| `/a/thing/extra` returns a static asset or 404 | none | `[slug].js` matches exactly one segment | Rename to `[[slug]].js` and read `context.params.slug` as an array |
| POST returns 405 while GET works | none | The file exports `onRequestGet` only | Add `onRequestPost`, or export `onRequest` and branch on `request.method` |
| Handler dies partway under load, no message | Error 1102, `Worker exceeded resource limits` | CPU time over 10 ms (free) or the configured paid ceiling | CPU excludes I/O waiting — profile actual computation; raise `limits.cpu_ms` on paid |
| Handler fails after roughly 50 binding calls | none in the response | Subrequest ceiling: 50 per invocation on free | Batch the queries, or move the loop to a Queue consumer |
| A font or image request bills as an invocation | none | `_routes.json` missing, or the path is not in `exclude` | Add the prefix to `exclude`; exclude always beats include |
| `_routes.json` rejected at deploy | none in the response | Over 100 include and exclude rules combined, a rule over 100 characters, or zero include rules | Collapse rules into wildcards; at least one include rule is mandatory |

## Related

- [One Cloudflare account, one build](/a/cloudflare-os) — the map of every component and which binding reaches it.
- [Workers and Durable Objects](/a/cloudflare-os-workers) — the six things that are separate Workers, and the three tests for when a job stops belonging in this deployment.

## The fresh compiler receipt moved the file count, not the architecture

Wrangler 4.103.0 compiled the current working tree at `2026-07-26T05:55:10.960Z`. The measurement wrote its output only to a temporary directory.

| Fresh check | Result |
| --- | ---: |
| JavaScript modules under `functions/` | 391 |
| Route files outside `functions/_lib/` | 224 |
| Shared `_lib` modules | 167 |
| Files exporting at least one request handler | 214 |
| Request-handler exports | 271 |
| `onRequestGet` exports | 170 |
| Single-segment `[param].js` routes | 19 |
| Optional catch-all `[[path]].js` routes | 32 |
| Root middleware files | 1; 947 lines |
| `_routes.json` rules | 1 include + 15 exclude |
| Compiled source | 5,325,637 bytes |
| Gzipped bundle | 1,350,472 bytes; 45.0% of the 3 MB free ceiling |

The three extra modules since the 25 July inventory produced one extra route file, two extra shared modules, one extra handler-bearing file and one extra GET export. The deployment shape did not change: every route and shared module still landed in one Worker bundle.

Reproduce the bundle measurement:

```bash
OUTDIR="$(mktemp -d)"
npx wrangler pages functions build --outdir "$OUTDIR"
stat -f "%z" "$OUTDIR/index.js"          # macOS
gzip -c "$OUTDIR/index.js" | wc -c
```

Expected proof line: `✨ Compiled Worker successfully`. Judge the plan limit against the gzip result, not the first number.

## Sources

1. Pages Functions routing — https://developers.cloudflare.com/pages/functions/routing/
2. Pages Functions API reference — https://developers.cloudflare.com/pages/functions/api-reference/
3. Pages Functions middleware — https://developers.cloudflare.com/pages/functions/middleware/
4. Pages _routes.json routing controls — https://developers.cloudflare.com/pages/functions/routing/
5. Cloudflare Pages limits — https://developers.cloudflare.com/pages/platform/limits/
6. Cloudflare Workers limits — https://developers.cloudflare.com/workers/platform/limits/
7. Pages Functions debugging and logging — https://developers.cloudflare.com/pages/functions/debugging-and-logging/
8. Migrate from Pages to Workers — https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/
9. Pages Direct Upload — https://developers.cloudflare.com/pages/get-started/direct-upload/
10. Pages Functions local development — https://developers.cloudflare.com/pages/functions/local-development/
11. OpenNext Cloudflare bundle troubleshooting — https://opennext.js.org/cloudflare/troubleshooting
12. Cloudflare workers-sdk — https://github.com/cloudflare/workers-sdk
13. Independent WASM bundle-limit reproduction — https://github.com/nuxt-modules/og-image/issues/193
14. How to fix workers.api.error.script_too_large when deploying Sveltekit to Cloudflare pages — https://stackoverflow.com/questions/77173778/how-to-fix-workers-api-error-script-too-large-when-deploying-sveltekit-to-cloudf
15. Deploying on Cloudflare Pages, script_too_large? — https://github.com/nuxt-modules/og-image/issues/193
16. 🐛 BUG: pages publish very slow / crashing — https://github.com/cloudflare/workers-sdk/issues/1194
17. Cloudflare recommends migrating from Pages to Workers — https://news.ycombinator.com/item?id=44854848
18. Magit manuals are available online again — https://news.ycombinator.com/item?id=45936226
19. Claude 3.7 Sonnet and Claude Code — https://news.ycombinator.com/item?id=43164386
20. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43646198
21. Cloudflare recommends migrating from Pages to Workers — https://news.ycombinator.com/item?id=44855519
22. 5 months ago I'd never coded anything. I now have full-stack analytics platform — https://news.ycombinator.com/item?id=47146087
23. Fresh first-party Functions tree inventory — https://miscsubjects.com/api/articles/cloudflare-os-functions
24. Fresh first-party Pages Functions compilation — https://miscsubjects.com/api/articles/cloudflare-os-functions
25. First-party cold and warm request timing — https://miscsubjects.com/api/articles/cloudflare-os-functions
26. First-party overlapping-route receipt — https://miscsubjects.com/api/articles/cloudflare-os-functions
27. First-party deploy-output receipt — https://miscsubjects.com/api/articles/cloudflare-os-functions
28. Fresh first-party _routes.json inventory — https://miscsubjects.com/api/articles/cloudflare-os-functions


---

# R2 cuts a 10 TB delivery bill from $923 to $18.45

slug: cloudflare-os-r2 · https://miscsubjects.com/a/cloudflare-os-r2 · tags: cloudflare, architecture, r2, cloudflare-os · updated 2026-07-26T03:59:27.570Z

Cloudflare R2 is object storage: give it a key like `img/up/hero.png` and some bytes, and it hands them back on request. It speaks two dialects: the Amazon S3 HTTP API, so existing S3 tools work against it, and a native binding inside a Cloudflare Worker where the bucket is a JavaScript object with `put`, `get`, `list` and `delete`. Objects go to 5 TiB, keys to 1,024 bytes, and a bucket holds any number of them.

The reason anyone brings it up is the price of getting bytes *out*. Amazon charges for that. Cloudflare does not. The rest of this page is the arithmetic that follows, plus the things R2 will not do for you.

[[embed:source:s2]]

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Zero egress is real; the meter is on the operations

R2 bills three things: how much you store, and two classes of API call. **Class A** operations change state: `PutObject`, `CopyObject`, `ListObjects`, `CreateMultipartUpload`, `UploadPart`, `CompleteMultipartUpload`. **Class B** operations read state: `GetObject`, `HeadObject`, `HeadBucket`. Deletes are free. Bandwidth to the internet is free.

Rates, read from the pricing page on 2026-07-26:

| | Standard | Infrequent Access |
| --- | --- | --- |
| Storage | $0.015 / GB-month | $0.01 / GB-month |
| Class A (writes, lists) | $4.50 / million | $9.00 / million |
| Class B (reads) | $0.36 / million | $0.90 / million |
| Data retrieval | none | $0.01 / GB |
| Egress to the internet | free | free |
| Free each month | 10 GB-month, 1M Class A, 10M Class B | none; the free tier is Standard only |
| Minimum storage duration | none | 30 days |

Cloudflare rounds usage up to the next unit: 1,000,001 operations bills as two million, 1.1 GB-month bills as 2 GB-month.

[[embed:source:s1]]

A reader of that same page put the obvious objection plainly.

[[embed:source:s21]]

He is describing the meter correctly and drawing the wrong conclusion. Price the overage. Class B beyond the free 10 million costs $0.36 per additional million. On the ten-terabyte workload computed below, the S3 egress line is $891.00. For R2's metered operations to cost that much, you would have to make **2.475 billion** Class B calls beyond the free allowance in one month: 891 ÷ 0.36 × 1,000,000. The class-ops meter is real. It is roughly three orders of magnitude away from being the thing that costs you money.

## Serving 10 TB a month: $18.45 on R2, $923.00 on S3

The workload: 1,000 GB stored, average object 500 KB (so 2,000,000 objects), 200,000 new objects written during the month, and 10,000 GB served to the public internet, or 20,000,000 GET requests. S3 prices are `us-east-1`, pulled from the AWS Price List API on 2026-07-26: Standard storage $0.023/GB-month, Tier-1 requests (PUT, COPY, POST, LIST) $0.005 per 1,000, Tier-2 requests (GET and all others) $0.0004 per 1,000, data transfer out to the internet $0.09/GB for the first 10 TB beyond the 100 GB monthly free allowance.

| Line | R2 arithmetic | R2 | S3 arithmetic | S3 |
| --- | --- | --- | --- | --- |
| Storage | (1,000 − 10 free) × $0.015 | $14.85 | 1,000 × $0.023 | $23.00 |
| Writes | 200,000 of 1,000,000 free | $0.00 | 200,000 × $0.005/1,000 | $1.00 |
| Reads | (20,000,000 − 10,000,000) × $0.36/M | $3.60 | 20,000,000 × $0.0004/1,000 | $8.00 |
| Egress | 10,000 GB, unmetered | $0.00 | (10,000 − 100) × $0.09 | $891.00 |
| **Total** | | **$18.45** | | **$923.00** |

Fifty times cheaper, and the ratio is almost entirely one line: egress is 96.5% of the S3 bill. The table also shows that R2's operation rates are not a gimmick to claw the egress back. Class A at $4.50/million undercuts S3's Tier-1 at $5.00/million, and Class B at $0.36/million undercuts Tier-2 at $0.40/million. R2 is 10% cheaper per call *and* free on bandwidth.

[[embed:source:s15]]

Cloudflare's CTO stated the free-tier gap when R2 launched, and the allowances he named still hold on the page fetched today.

[[embed:source:s19]]

Two published totals for real public-serving workloads, both itemised. A 33 GB WordPress plugin mirror on R2 plus Workers, and a full archive of every SEC filing served at roughly twice the SEC's own volume:

[[embed:source:s24]]

[[embed:source:s26]]

Neither workload needed versioning, and neither was cold. That is why they land where they do.

## The workload where S3 wins is the one you never read

R2 has exactly two storage classes. S3 has a ladder that goes much colder. For a write-once archive that is almost never read and never leaves the cloud, the egress advantage is worth nothing and the storage floor decides.

The workload: 100,000 GB (100 TB) of compliance records, written once, read a handful of times a year, consumed inside the same cloud.

| Where it sits | Rate | Monthly |
| --- | --- | --- |
| R2 Standard | (100,000 − 10) × $0.015 | $1,499.85 |
| R2 Infrequent Access | 100,000 × $0.01 (no free tier applies) | $1,000.00 |
| S3 Glacier Flexible Retrieval | 100,000 × $0.0036 | $360.00 |
| S3 Deep Archive Access tier | 100,000 × $0.00099 | $99.00 |

R2's cheapest class costs 2.8× the Glacier Flexible rate and 10.1× the Deep Archive rate. There is no colder tier to move to; Standard and Infrequent Access are the whole ladder. If your bytes are cold and captive, stay on S3.

[[embed:source:s5]]

The person who ran both and stayed on AWS drew the boundary in one sentence.

[[embed:source:s22]]

## Infrequent Access pays only below about one read every two months

Infrequent Access looks like a third off the storage price. The retrieval fee eats it almost immediately. Take a 1 GB object held for one month and read `r` times:

- Standard: `$0.015 + r × $0.00000036`
- Infrequent Access: `$0.010 + r × $0.0000009 + r × 1 GB × $0.01`

Set them equal: `0.005 = 0.01000054r`, so `r ≈ 0.5`. The retrieval fee is 99.99% of the right-hand side; the operation-rate difference is noise. **A 1 GB object must be read less than once every two months for Infrequent Access to be cheaper.** Add the 30-day minimum billing duration, where you pay a full month even if you delete on day two, and the class is for backups and cold originals, nothing else.

Move objects there with a lifecycle rule rather than by hand. The transition itself is billed as a Class A operation.

```bash
npx wrangler r2 bucket lifecycle add miscsubjects-ledger \
  --name "archive-old-events" \
  --prefix "events/" \
  --storage-class InfrequentAccess \
  --transition-days 30
```

Lifecycle rules also delete: an expiration rule on a prefix is how an ingest bucket stops growing forever, and how incomplete multipart uploads get cleaned up. They expire after 7 days by default. A bucket accepts up to 1,000 rules.

[[embed:source:s6]]

## R2 will not keep the old version of an object

This is the gap that costs people data. When you `put` to a key that already exists, the previous bytes are gone.

[[embed:source:s23]]

The S3 compatibility table marks `PutBucketVersioning`, `GetBucketVersioning`, `PutObjectLockConfiguration` and `GetObjectLockConfiguration` all unsupported. Versioning and Object Lock are the two standard defences against ransomware and against a human running the wrong script; R2 offers neither in S3's form.

[[embed:source:s4]]

It does have **bucket locks**, a per-prefix retention rule blocking deletion and overwriting for a fixed period or indefinitely, enforced with `10069 / ObjectLockedByBucketPolicy` and HTTP 403. That stops deletion. It does not give you "fetch me yesterday's copy".

[[embed:source:s7]]

For that you write versioning yourself: never overwrite a key, always write a new one, keep a pointer.

```js
// Content-addressed writes: the key carries the version, so nothing is ever overwritten.
export async function putVersioned(env, logicalKey, body, contentType) {
  const bytes = body instanceof ArrayBuffer ? body : new TextEncoder().encode(body);
  const digest = await crypto.subtle.digest('SHA-256', bytes);
  const hash = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('');
  const versionKey = `${logicalKey}/${Date.now()}-${hash.slice(0, 12)}`;
  await env.R2.put(versionKey, bytes, { httpMetadata: { contentType } });
  // The pointer is one small object; reading it is one Class B call.
  await env.R2.put(`${logicalKey}/current`, versionKey, {
    httpMetadata: { contentType: 'text/plain' },
  });
  return { versionKey, hash };
}

export async function getCurrent(env, logicalKey) {
  const ptr = await env.R2.get(`${logicalKey}/current`);
  if (!ptr) return null;
  return env.R2.get(await ptr.text());   // second Class B call
}
```

The cost of doing it this way: every read is two Class B operations instead of one, and old versions accumulate until a lifecycle rule expires them. At $0.36 per million reads, the doubled read is $0.36 per million objects fetched. That is the actual price of the missing feature.

## The free-egress trust question, answered from the terms

The sharpest objection is not about the price list but about whether it is load-bearing.

[[embed:source:s25]]

The worry points at a real clause, and the clause says the opposite of what it assumes. Cloudflare's Service-Specific Terms restrict the **CDN** on Free, Pro and Business plans: *"Unless you are an Enterprise customer, Cloudflare offers specific Paid Services (e.g., the Developer Platform, Images, and Stream) that you must use in order to serve video and other large files via the CDN."* The Developer Platform named there is the thing R2 belongs to. Using R2 to serve large files is the compliant path, not the risky one.

The Developer Platform section carries its own limit, and it is a different kind: *"Cloudflare may temporarily limit your storage and/or the number of requests you can make or receive using the Developer Platform if processing such requests would put an undue burden on the Cloudflare network."* Rate limiting, stated in advance. Not a retroactive per-GB bill.

Verdict: the enforcement risk on R2 is throttling under abnormal load, not an undisclosed egress charge. The pricing page's footnote is unambiguous: *"Egressing directly from R2, including via the Workers API, S3 API, and r2.dev domains does not incur data transfer (egress) charges and is free."* What it does not cover is metered services you bolt on top; those bill separately.

[[embed:source:s14]]

## Every call, and which meter it hits

Bind the bucket first. In `wrangler.toml`:

```toml
[[r2_buckets]]
binding = "R2"
bucket_name = "miscsubjects-ledger"
```

`binding` is the variable name your code sees; `bucket_name` is the real bucket. Create the bucket before the first deploy, or the binding fails:

```bash
npx wrangler r2 bucket create miscsubjects-ledger
npx wrangler r2 bucket list
```

| Call | What it does | Meter |
| --- | --- | --- |
| `env.R2.put(key, value, opts)` | Stores bytes; returns an `R2Object`. Strongly consistent: once the promise resolves, every reader worldwide sees it | Class A |
| `env.R2.get(key, opts)` | Returns `R2ObjectBody` with `.body` as a stream, or `null` if absent | Class B |
| `env.R2.head(key)` | Metadata only, no body, or `null` | Class B |
| `env.R2.list(opts)` | Up to 1,000 keys, lexicographic, `opts.prefix` to scope | Class A |
| `env.R2.delete(key or key[])` | Up to 1,000 keys per call | free |
| `env.R2.createMultipartUpload(key)` | Starts a multipart upload | Class A |
| `upload.uploadPart(n, body)` | One part; all non-final parts must be the same size and ≥ 5 MiB | Class A each |
| `upload.complete(parts)` | Finishes it | Class A |

[[embed:source:s3]]

Note the trap in that table: **`list` is a Class A operation**, priced 12.5× a read. A paginated file browser that lists on every page view burns the expensive quota, not the cheap one.

A single `put` accepts up to 5 GiB. Above that, multipart, or you get `100100 / EntityTooLarge`:

```js
const upload = await env.R2.createMultipartUpload('big/archive.tar');
const parts = [];
let n = 1;
for (const chunk of chunksOf(stream, 16 * 1024 * 1024)) {   // 16 MiB, uniform
  parts.push(await upload.uploadPart(n++, chunk));
}
await upload.complete(parts);
```

From outside a Worker, use the S3 API against `https://<ACCOUNT_ID>.r2.cloudflarestorage.com` with `region: "auto"`, or hand a browser a presigned URL so it uploads straight to R2 without the bytes passing through your server:

```js
import { AwsClient } from 'aws4fetch';

const r2 = new AwsClient({ accessKeyId: ACCESS_KEY_ID, secretAccessKey: SECRET_ACCESS_KEY });
const url = new URL(`https://${ACCOUNT_ID}.r2.cloudflarestorage.com/my-bucket/uploads/${name}`);
url.searchParams.set('X-Amz-Expires', '3600');           // seconds
const signed = await r2.sign(new Request(url, { method: 'PUT' }), {
  aws: { signQuery: true, service: 's3' },
});
// signed.url is safe to hand to a browser; it expires in one hour.
```

Tamper with any signature parameter and the request fails with `10035 / SignatureDoesNotMatch`; let it age out and you get `10018 / ExpiredRequest`.

[[embed:source:s9]]

## Serving an object publicly, and the header that decides your bill

Three ways to make a bucket readable from the internet. A **custom domain** puts it behind a hostname on your zone, the only option that gets Cloudflare Cache, WAF rules and Bot Management. An **r2.dev subdomain** is one toggle, documented as non-production. A **Worker route** gives you the object plus whatever logic you put in front of it. The third, in nine lines:

[[embed:source:s8]]

```js
// functions/img/[[path]].js: every /img/* URL is an R2 key.
export async function onRequestGet(context) {
  const { params, env } = context;
  const key = 'img/' + (Array.isArray(params.path) ? params.path.join('/') : String(params.path || ''));
  if (!env.R2) return new Response('no R2', { status: 500 });
  const obj = await env.R2.get(key);
  if (!obj) return new Response('not found', { status: 404 });
  const ct = obj.httpMetadata?.contentType || 'image/png';
  return new Response(obj.body, { headers: { 'content-type': ct, 'cache-control': 'public, max-age=31536000' } });
}
```

The URL path *is* the object key: no media table, no second name for a file, and a rename is a copy.

`cache-control: public, max-age=31536000` is the line that matters financially. Cached responses never reach R2, so they cost no Class B operation. Fetched live, the header comes back on the wire:

```text
$ curl -sI https://miscsubjects.com/img/up/cloudflare-os-r2-hero-card.png
HTTP/2 200
content-type: image/png
content-length: 51205
cache-control: public, max-age=31536000
cf-cache-status: MISS
cf-ray: a210b8bcbb715616-SJC
```

A one-year max-age is only safe because a changed image is written under a new key, never patched in place. Serve mutable objects this way and you will serve stale bytes for a year.

[[embed:source:s17]]

Cache-hit ratio is not a rounding error on the bill:

[[embed:source:s20]]

## When a row outgrows D1, the bytes move to R2 and the row keeps a pointer

D1, the SQLite database in the same account ([D1 as the spine](/a/cloudflare-os-d1)), has a hard per-value ceiling: **2,000,000 bytes** for any string, BLOB or row. Exceed it and every write to that row fails with `D1_ERROR: string or blob too big`, the driver's rendering of SQLite's `SQLITE_TOOBIG`.

This application hit that wall storing article revision history. Each write snapshots the previous version — full body, claims and sources — into a JSON blob on the row. One article's metadata reached **2,068,258 bytes** across 24 snapshots, 68,258 over the cap, and from then on every write to it returned HTTP 500.

The fix generalises into a rule: **when a field outgrows its row, the field moves to object storage and the row keeps a pointer plus a hash.** The pointer is small and fixed-size; the hash is what makes the pointer trustworthy.

The key format is one line in `functions/_lib/revisions_r2.js`:

```js
const PREFIX = "revisions/";
function r2Key(slug, n) { return `${PREFIX}${slug}/${n}.json`; }
```

So revision 3 of this article lives at `revisions/cloudflare-os-r2/3.json`. What stays in D1 is a nine-field index entry, written by the same function:

```js
return {
  n: full.n, ts: full.ts, title: full.title, status: full.status,
  register: full.register, bytes: full.body.length,
  prev_hash: full.prev_hash, hash: full.hash,
  r2_key: key,
};
```

`prev_hash` and `hash` are the point: the chain is verifiable from D1 alone, no R2 read needed to prove the history has not been edited, while the bytes are fetched only when someone asks for a specific revision. That 2,068,258-byte row became **206,362 bytes**, and the original revision is still retrievable.

[[embed:source:s16]]

Measured across the bucket today: 4,930 objects under `revisions/`, 148,075,146 bytes, 1,053 distinct articles, mean 30,036 bytes per revision. The largest single history, `revisions/bpc-157/`, is 119 objects and 14,503,688 bytes — 7.25× the D1 row cap, so that article alone would be permanently unwritable under the old scheme.

Proof the offload preserved the history rather than truncating it:

```bash
$ curl -s "https://miscsubjects.com/api/articles/cloudflare-os-r2?rev=0" | jq '{rev,is_head,prev_hash,body_len:(.body|length)}'
{ "rev": 0, "is_head": false, "prev_hash": "genesis", "body_len": 2878 }
```

That body came out of R2. Nothing but the index is in the database.

## Choosing between R2, S3, KV, D1 and Durable Object storage

| Workload | Put it in | Why, and the limit that decides it |
| --- | --- | --- |
| Images, video, uploads, anything served to the public internet | **R2** | Free egress; 5 TiB per object; strongly consistent |
| Cold archive, rarely read, consumed inside AWS | **S3 Glacier** | $0.0036–$0.00099/GB-month against R2's $0.01 floor |
| A file with a legal retention requirement and a need to read yesterday's copy | **S3** | R2 has bucket locks but no object versioning |
| Small values read constantly from many places — flags, prompts, rendered snapshots | **Workers KV** | Values to 25 MiB, but eventually consistent and 1 write/second per key |
| Anything you need to query, join, filter or index | **D1** | 10 GB per database, 2 MB per value; a bucket cannot answer `WHERE` |
| Per-entity state needing serialized writes and coordination | **Durable Object storage** | 10 GB per object, single-threaded execution ([the Workers layer](/a/cloudflare-os-workers)) |
| A row that has outgrown 2 MB | **R2, with a pointer in D1** | The offload pattern above |
| Blobs written and read once, under 25 MiB, with no need for a URL | **either KV or R2** | R2 unless you need sub-millisecond reads at the edge |

[[embed:source:s10]]

[[embed:source:s11]]

[[embed:source:s12]]

[[embed:source:s13]]

Two failure modes worth naming: KV used as a database (eventually consistent, so a read after a write may return the old value), and R2 used as a database (no query, only a lexicographic key scan at Class A prices).

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| `no R2 binding` / `no R2`, HTTP 500 | The Worker deployed without the `r2_buckets` entry, or the bucket does not exist yet | `npx wrangler r2 bucket create <name>`, then confirm the `[[r2_buckets]]` block is present in every environment, including `[[env.preview.r2_buckets]]` |
| `10006 / NoSuchBucket`, HTTP 404 | Bucket name typo, or the bucket is in a different account | `npx wrangler r2 bucket list` and compare byte for byte |
| `get()` returns `null` instead of throwing | Not an error — the Workers API returns `null` for a missing key rather than raising `NoSuchKey` | Branch on `null`; do not wrap in `try/catch` and expect a throw |
| `100100 / EntityTooLarge`, HTTP 400 | Single-part upload over 5 GiB | Switch to `createMultipartUpload`; parts uniform and ≥ 5 MiB |
| `10011 / EntityTooSmall` or `10048 / InvalidPart`, HTTP 400 | A non-final part under 5 MiB, or parts of differing sizes | Every part except the last must be ≥ 5 MiB and identical in size |
| `10058 / TooManyRequests`, HTTP 429 | More than one write per second to the same key | Shard the key, or funnel writes for that key through a Durable Object |
| `10035 / SignatureDoesNotMatch`, HTTP 403 | A presigned URL was edited, or the secret is wrong | Regenerate; check URL encoding of the key |
| `10069 / ObjectLockedByBucketPolicy`, HTTP 403 | A bucket lock retention rule covers that prefix | Wait out the retention period; a lock is not overridable |
| `D1_ERROR: string or blob too big` on a write that used to work | A JSON column crossed D1's 2,000,000-byte per-value cap | Offload the heavy field to R2, keep a pointer and a hash in the row |
| `wrangler r2 object put --file` fails with `fetch failed` | Reported against Wrangler 3.74.0 and still open | Pipe the file instead: `cat f.txt \| npx wrangler r2 object put bucket/f.txt --pipe` |
| Multipart `complete()` returns empty `customMetadata` in production but not in `wrangler dev` | Open bug, reported against Wrangler 3.57.2 | Re-read with `head()` after completing; the metadata is stored, only the return value drops it |

## How the numbers on this page were measured

Four measurements, taken 2026-07-26 against the live production bucket from a laptop served by the San Jose edge (`cf-ray` suffix `SJC`).

[[embed:source:s27]]

**1 — Buckets and inventory.** `npx wrangler r2 bucket list` returns three buckets: `loop-data-raw` (2026-05-31), `miscsubjects-ledger` (2026-06-09), `miscsubjects-store` (2026-06-16). The inventory of `miscsubjects-ledger` was walked through the authenticated list route, 1,000 keys per page, following the cursor:

```bash
curl -s -H "x-terminal-key: $TERMINAL_KEY" \
  "https://miscsubjects.com/api/r2/?list=1&limit=1000&cursor=$CURSOR"
```

62 pages, **41,809 objects, 3,904,595,147 bytes (3.64 GiB)** — under the 10 GB free allowance, so today's storage line is $0.00. By prefix: `events/` 34,258 objects / 2,241 MB; `revisions/` 4,930 / 148 MB; `img/gen/` 387 / 765 MB; `img/up/` 191 / 432 MB; `docs/` 1,332 / 9.5 MB. The walk itself cost 62 Class A operations.

**2 — Put and get latency.** A 65,536-byte JSON object written and read six times each over one reused TLS connection, at the deliberately-named temporary key `tmp/measure-2026-07-25-delete-me.json`, then deleted — `DELETE` returned `{"ok":true,...,"deleted":true}` and a follow-up list of `tmp/` returned zero objects.

```bash
curl -s -X PUT "https://miscsubjects.com/api/r2/tmp/measure-2026-07-25-delete-me.json" \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' \
  --data-binary @probe.json -w "%{time_total}\n"
```

PUT round trips: 535, 587, 634, 737, 801, 2,471 ms — median **686 ms**. GET: 96, 104, 125, 148, 185, 192 ms — median **136 ms**. These are full client-to-edge-to-R2-to-client times through the Worker, not R2's internal service time; the first request in each series carries the TLS handshake, 86 ms and 71 ms.

**3 — Public object headers.** `curl -sI https://miscsubjects.com/img/up/cloudflare-os-r2-hero-card.png` returned HTTP 200, `content-length: 51205`, `content-type: image/png`, `cache-control: public, max-age=31536000`, `cf-cache-status: MISS`. Full body fetch, 189 ms.

[[embed:source:s18]]

**4 — A revision read out of R2.** `curl -s "https://miscsubjects.com/api/articles/cloudflare-os-r2?rev=0"` returned `rev: 0`, `is_head: false`, `prev_hash: "genesis"`, `hash: 24915ae889f6184140a56e89d476d28a31e0cf0e28d31c0990c2aab6f7a626fc`, body length 2,878 — out of `revisions/cloudflare-os-r2/0.json`, not the database. Account identifiers are omitted throughout; the S3 endpoint appears as `<ACCOUNT_ID>.r2.cloudflarestorage.com`.

The index for the rest of this stack is [the Cloudflare account as an operating system](/a/cloudflare-os).


## Sources

1. Cloudflare R2 pricing — https://developers.cloudflare.com/r2/pricing/
2. How R2 works — https://developers.cloudflare.com/r2/how-r2-works/
3. Workers R2 API reference — https://developers.cloudflare.com/r2/api/workers/workers-api-reference/
4. S3 API compatibility — https://developers.cloudflare.com/r2/api/s3/api/
5. R2 storage classes — https://developers.cloudflare.com/r2/buckets/storage-classes/
6. R2 object lifecycles — https://developers.cloudflare.com/r2/buckets/object-lifecycles/
7. R2 bucket locks — https://developers.cloudflare.com/r2/buckets/bucket-locks/
8. R2 public buckets — https://developers.cloudflare.com/r2/buckets/public-buckets/
9. R2 presigned URLs — https://developers.cloudflare.com/r2/api/s3/presigned-urls/
10. R2 limits — https://developers.cloudflare.com/r2/platform/limits/
11. Workers KV limits — https://developers.cloudflare.com/kv/platform/limits/
12. D1 limits — https://developers.cloudflare.com/d1/platform/limits/
13. Durable Objects limits — https://developers.cloudflare.com/durable-objects/platform/limits/
14. Cloudflare Developer Platform terms — https://www.cloudflare.com/service-specific-terms-developer-platform/
15. AWS S3 us-east-1 Price List API — https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonS3/current/us-east-1/index.json
16. Clarify presigned URL docs — https://github.com/cloudflare/cloudflare-docs/issues/19190
17. Unable to put an object with a key containing three dots to R2 — https://github.com/cloudflare/workers-sdk/issues/3520
18. Cloud object storage benchmark — https://aimultiple.com/cloud-object-storage
19. The next chapter for Cloudflare Workers: open-source — https://news.ycombinator.com/item?id=31314273
20. How Canva saves Amazon S3 costs — https://news.ycombinator.com/item?id=36392670
21. WordPress Plugin Mirror Downloader (Proof of Concept) — https://news.ycombinator.com/item?id=41753985
22. Ask HN: S3(AWS) vs R2(CF)–Which is better? — https://news.ycombinator.com/item?id=47700966
23. Comparing AWS S3 with Cloudflare R2: Price, Performance and User Experience — https://news.ycombinator.com/item?id=42257068
24. Hetzner continues its growth in the US with a new location — https://news.ycombinator.com/item?id=33865229
25. Comparing AWS S3 with Cloudflare R2: Price, Performance and User Experience — https://news.ycombinator.com/item?id=42257094
26. Cloudflare R2 let me serve almost twice as much data this month as the SEC for $10.80 — https://old.reddit.com/r/CloudFlare/comments/1qhbrey/cloudflare_r2_let_me_serve_almost_twice_as_much/
27. Live R2 object headers — https://miscsubjects.com/img/up/cloudflare-os-r2-hero-card.png


---

# Workers KV makes reads fast by making writes slow and consistency optional

slug: cloudflare-os-kv · https://miscsubjects.com/a/cloudflare-os-kv · tags: cloudflare, architecture, kv, cloudflare-os, workers-kv, cache, eventual-consistency, durable-objects, pricing · updated 2026-07-26T03:59:24.251Z

Workers KV is a key-value store with one central copy of your data and a cache of that copy in every Cloudflare location that has recently asked for it. Reads from a location that already holds the key are the fastest storage read on the platform. Writes go to the centre and take their time getting everywhere else. Every decision on this page follows from that one asymmetry.

The short answer to "should this state live in KV": if losing sixty seconds of freshness in another continent is survivable, yes. If two requests might write the same key at the same time and the result has to be correct, no.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Cloudflare's own Workers lead calls the database use a misuse

Kenton Varda, who leads the Workers team, answered a developer who had adopted KV as their datastore:

> KV is not a distributed database and is really not intended as a database alternative at all. It's more meant for distributing bits of config globally. Cost aside, writes are way too slow for database-ish use

He pointed at Durable Object SQLite storage and at Hyperdrive instead. Take the sentence literally: **bits of config**. Flags, routing tables, rendered snapshots, allow-lists, prompt blocks. Not carts, not counters, not sessions that mutate, not anything two writers touch.

[[widget:0]]

## Eventual consistency, in the exact words of the reference

The Workers Binding API reference states the write behaviour without softening it:

> Due to the eventually consistent nature of KV, concurrent writes to the same key can end up overwriting one another.

and

> Writes are immediately visible to other requests in the same global network location, but can take up to 60 seconds (or the value of the `cacheTtl` parameter of the `get()` or `getWithMetadata()` methods) to be visible in other parts of the world.

The read reference is equally blunt: `get()` and `getWithMetadata()` "may return stale values". The concepts page adds the trap most people miss — **a miss is cached too**:

> Negative lookups indicating that the key does not exist are also cached, so the same delay exists noticing a value is created as when a value is changed.

So a location that asked for `flag:new_checkout` before you created it will keep answering `null` for up to sixty seconds after the key exists. Nothing retries on your behalf.

### What a reader in another region actually sees after a write

| Moment after the write | Same location as the writer | A location that has never read the key | A location that read the key (or its absence) recently |
| --- | --- | --- | --- |
| 0–1 s | New value, usually | New value — nothing cached to serve instead | Old value, or `null` |
| 1–60 s | New value | New value | Old value, or `null`, until the cached copy times out |
| After 60 s | New value | New value | New value |
| With `cacheTtl: 3600` set on the read | New value | New value | Old value for up to an hour |

"Usually" is the documentation's word, not a hedge added here: *"At the Cloudflare global network location at which changes are made, these changes are usually immediately visible. However, this is not guaranteed and therefore it is not advised to rely on this behaviour."* There is no read-after-write guarantee anywhere in KV, including at the writing location.

### The safety rule, applied to real states

| State | Safe in KV | Why |
| --- | --- | --- |
| Rendered page snapshot | Yes | A stale page is a slightly old page. The next render replaces it. |
| Feature flag, kill switch | Yes | Rollout is a minute, not a millisecond. One writer, an operator. |
| Routing table, agent prompt block | Yes | Changes are deliberate and infrequent; a minute of skew is invisible. |
| Allow-list / deny-list | Yes, with a caveat | Adding is fine. Revocation is not — a revoked entry stays live for the propagation window. Pair with a short `cacheTtl` or a second, authoritative check. |
| Session state that mutates per request | No | Read-modify-write on the same key. Concurrent writes overwrite each other. |
| Counter, quota, rate limit | No | Same lost-update problem, every increment. |
| Shopping cart, order status | No | Two tabs, two writes, one survivor, no error. |
| A lock over anything contended | No | See the lock section below. |
| The only copy of any fact | No, except flags | Nothing to rebuild it from when a write is lost. |

## The rates, and the one that is ten times the others

Fetched from Cloudflare's KV pricing page today. All rates are per operation on a **per-key** basis; a bulk read of 50 keys is 50 billable reads.

| Operation | Workers Free | Workers Paid (included, then rate) |
| --- | --- | --- |
| Read | 100,000 / day | 10 million / month, then $0.50 / million |
| Write | 1,000 / day | 1 million / month, then $5.00 / million |
| Delete | 1,000 / day | 1 million / month, then $5.00 / million |
| List | 1,000 / day | 1 million / month, then $5.00 / million |
| Stored data | 1 GB | 1 GB, then $0.50 / GB-month |

Two consequences worth stating flatly. **A write costs the same as ten reads.** And **a miss is billable**: "All operations incur charges, including fetches for non-existent keys that return a null (Workers API) or HTTP 404 (REST API)." A cache-aside pattern that checks KV before hitting a database pays for every check, hit or miss. Egress is free.

Free-plan writes are the real cliff. One thousand writes a day is roughly one write every ninety seconds, sustained. Any per-request write pattern exhausts it before lunch.

[[widget:1]]

## kondro's 2021 arithmetic still prices out correctly in 2026

Five years ago, on a Hacker News thread about R2 pricing, a commenter laid out the KV objection:

> Workers KV is also eventually-consistent with no guarantee of read-after-write, which is a pretty big limitation compared to alternatives (S3 even has immediately-consistent list operations now after write).

The same comment put KV at $5 per million writes and $0.50 per million reads, called the reads pricier than S3's, and set that against Durable Object storage at $1 per million 4 KB writes with the Durable Object runtime cost stacked on top. Checked against today's published pages:

| kondro's 2021 figure | Published rate, July 2026 | Verdict |
| --- | --- | --- |
| KV writes $5 / million | $5.00 / million | Unchanged |
| KV reads $0.50 / million | $0.50 / million | Unchanged |
| KV reads pricier per read than S3 | S3 Standard GET is "$0.0004 per 1,000 requests" = $0.40 / million | Still true. KV reads cost 25% more per operation. |
| Durable Object storage $1 / million writes | SQLite-backed Durable Object storage: $1.00 / million rows written, first 50 million / month included | Same rate, and the free allowance is now fifty times KV's |
| Durable Object runtime cost on top | $0.15 / million requests plus $12.50 / million GB-s of duration | Still stacked, and still the reason KV wins on pure read serving |

The one number that moved in KV's favour is nothing to do with KV: Durable Object storage now includes 50 million row writes a month against KV's 1 million. For a write-heavy key, a Durable Object is now cheaper *and* correct.

R2 is the other comparison people make and get wrong in KV's favour. R2 Class B operations — the reads — are **$0.36 per million**, cheaper than KV's $0.50, with 10 million a month free and 10 GB of storage free against KV's 1 GB. R2 loses on latency, not on price.

## Bounding writes by putting the edge cache in front of KV

The write rate, not the read rate, is what turns a KV bill into a surprise. An operator running a share-link backend described the defence, in a thread about a Durable Object alarm loop that had burned $34,000 in eight days:

> The key property is that caches.default with Cache-Control: max-age=3600 becomes a natural throttle — at most 24 cache misses per day per key, so KV writes are bounded by (keys × 24) regardless of traffic.

The mechanism, step by step:

1. The Worker checks `caches.default` first. A hit returns without touching KV at all — no read charge, no write charge.
2. Only a miss reaches KV. Only a miss can trigger the refresh write.
3. `Cache-Control: max-age=3600` means a given key can only miss once an hour per cache location.
4. Therefore the *write* count per key is bounded by the number of cache expiries, not by the number of requests. Traffic can multiply by a thousand and the write bill does not move.

**What it costs you:** freshness. A value written now is invisible behind that cache for up to an hour, on top of KV's own propagation window. You are choosing a bounded bill over a bounded staleness, and you cannot have both.

This codebase runs the same pattern with a shorter window. `functions/_middleware.js` sets `LASTGOOD_REFRESH_MS = 120000` and `refreshLastGood()` returns early when the stored snapshot is younger than that, so any one path writes its snapshot at most once per two minutes no matter how many misses arrive. The edge cache in front carries `public, max-age=120, s-maxage=600, stale-while-revalidate=86400` for article pages. The measured result is in the last section: 12,231 writes a day across 6,568 snapshot keys, against a theoretical ceiling of 6,568 × 720 = 4.7 million.

## The per-key boundaries, and the error you get at each one

| Limit | Value | What happens at the boundary |
| --- | --- | --- |
| Key size | 512 bytes | The operation is rejected. Long composite keys are the usual cause. |
| Value size | 25 MiB | Write rejected. Anything approaching this belongs in [R2](/a/cloudflare-os-r2). |
| Metadata size | 1024 bytes, serialized JSON | Write rejected. Metadata rides along with `list()` results, which is why it is worth keeping small deliberately. |
| Writes to the same key | 1 per second, free and paid alike | Excess writes fail. This is a hard rate limit, not a billing threshold. |
| Operations per Worker invocation | 1,000 | A bulk request counts as one. |
| `expirationTtl` minimum | 60 seconds | Shorter values are rejected. A sub-minute lease is not expressible. |
| `cacheTtl` minimum | 30 seconds | Below this the parameter is refused. |
| Namespaces per account | 1,000 | — |

The key-size limit is the one that bites in production because it fails late and looks like something else. A pull request against Cloudflare's own `vinext` framework describes it exactly:

> When the assembled key exceeds Cloudflare KV's 512-byte key limit, `handler.get` throws a 414 **before** the wrapped function runs — so control-flow signals like `notFound()`/`redirect()` never fire, and the user sees a generic 200 error boundary instead of a 404.

Their fix is the one to copy: budget for your prefix (they used 480 bytes to leave room for `<appPrefix>:cache:`), keep short keys verbatim so they stay debuggable, and hash only the overflowing part.

## An eventually-consistent store cannot hold a lock, and this application's locks are only safe because nobody is racing

The honest answer first. A lock needs compare-and-set: test that nobody holds it and take it, atomically, with no window between the test and the take. KV has no such primitive. `get()` then `put()` is two operations with a gap, and the reference already told you what happens in that gap — concurrent writes to the same key overwrite one another, last write wins, no error returned to the loser.

Three KV locks run in this application, all with the same shape:

- **`locks:deploy:loop-safe-miscsubjects`** — `functions/_lib/fn_runners.js`, the `deployLease` runner. Reads the key, returns `ERR:deploy_lease:held:` if a live lease exists, otherwise writes a lease with a random `nonce` and `expirationTtl: 1800`. Release requires presenting the matching nonce, so a stale holder cannot free somebody else's lease. `scripts/ship.mjs` takes this lease before every deploy.
- **`selftest:lock`** — `functions/api/selftest.js`. Same read-then-write, `expirationTtl: 1800`, with a 1,500,000 ms staleness window on the stored timestamp so an abandoned run does not block the next one forever.
- **`fclaim:*`** — advisory file claims so two coding agents do not edit the same file, default lease 90 minutes.

Each of these is a genuine race. Two `acquire` calls landing inside the same second both read no lease, both write, and the second write wins silently. What makes the pattern survivable here, and the condition must be said out loud:

**These locks are safe only because contention is near zero.** A deploy happens a few times a day, initiated by a human or one agent. A self-test run is a scheduled singleton. Two agents claiming the same file inside the same second is a coincidence, not a workload. Change any of those assumptions — a deploy fired by webhook on every push, a self-test on a one-minute cron — and the lock stops working, quietly, with no error to tell you.

The codebase already contains the correction for the case where contention is real. `functions/_lib/idem_claim.js` guards invoke idempotency, where duplicate parallel calls are the normal case rather than a coincidence, and its opening comment records why it is not in KV:

> KV get→fire→put races: parallel identical calls all miss, all fire.

It uses `INSERT OR IGNORE` on a D1 table instead, where the primary key does the atomic test-and-set that KV cannot. That is the rule generalised: **if two writers can plausibly arrive together, the lock goes in D1 or a Durable Object, not KV.** Cloudflare's own guidance says the same thing — "KV is not ideal for applications where you need support for atomic operations or where values must be read and written in a single transaction."

[[widget:2]]

## The topology teams settle on: authority elsewhere, KV as the replicated read copy

Asked how they ran a global read path, one operator described the shape that keeps recurring:

> Cloudflare Workers KV has the simplest model, with a central-db that transparently and eventually only replicates read-only, hot-data specific to a DC but writes continue to incur heavy penalty

Their production system used DynamoDB in a single region as the source of truth, DynamoDB Streams pushing changes into Workers KV, and reads served from KV at the edge. Writes never touched KV directly. The reasons they gave were operations per second, cost and latency — and avoiding lock-in.

The generalised topology, and it is the one to copy:

1. **Authority** — a store with transactions: D1, a Durable Object, Postgres behind Hyperdrive, DynamoDB. All writes land here and here only.
2. **Propagation** — a change feed, a queue, or the write path itself pushes the new value into KV as a side effect. One writer per key, which is exactly what the reference recommends: *"It is a common pattern to write data from a single process with Wrangler, Durable Objects, or the API. This avoids competing concurrent writes because of the single stream."*
3. **Read** — every edge read hits KV. It is allowed to be a minute stale because the authority, not KV, is what anybody reconciles against.

Two field reports bracket the tradeoff. On the positive side, the author of an edge feature-flag system:

> I mostly use KV for storing flags specific to each project (which gets replicated automatically). Everything else goes to D1 (replication isn't needed here).

On the negative side, the bind that pushes people into KV whether it fits or not:

> You can use KV, with its trade-off of eventual consistency, or use something like FaunaDB or Firebase, but that means that the request has to wait for the request to the backing service.

Both are true at once. KV is the only storage on the platform that is already next to the Worker; everything else is a network hop. That is the whole reason people put things in it that do not belong there.

And a measured case of KV in the cache role paying off: an operator repeatedly tripping D1's 5 million daily row-read limit put a KV layer in front and reported back a week later — *"I implemented KV-layered caching"* — with reads down more than 80% and back under the limit. That is KV doing the job it is for. See [D1 in this stack](/a/cloudflare-os-d1) for the read-accounting model that makes those limits bite.

## Where each kind of state belongs

| If the state is… | KV | [D1](/a/cloudflare-os-d1) | [R2](/a/cloudflare-os-r2) | Durable Object storage | Cache API |
| --- | --- | --- | --- | --- | --- |
| Read from everywhere, written rarely, seconds of staleness fine | **Use this** | Slower reads, and rows read are metered | Higher latency, cheaper per read | Single-location reads | Not durable |
| Relational, queried by more than a key | No | **Use this** | No | Only if scoped to one object | No |
| Large bytes: images, video, archives | No — 25 MiB ceiling | No | **Use this** — free egress, $0.015/GB-month | No | No |
| Coordination, counters, anything atomic | **Never** | Workable via `INSERT OR IGNORE` | No | **Use this** — single-threaded, transactional | No |
| Per-request ephemeral output, regenerable | Wasteful — pays a write | No | No | No | **Use this** — free, per-location, non-durable |
| The source of truth for money or identity | **Never** | Yes | Yes for blobs | Yes | Never |
| Sixty-second global propagation is unacceptable | No | Yes | Yes | Yes | Yes, per location |

The Cache API row deserves its own sentence because it is the cheapest option on the table and the most often skipped: `caches.default` costs nothing per operation, is not durable, and is scoped to one Cloudflare location. Put it in front of KV, as above, and it is what bounds the write bill.

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| A value written a second ago reads as the old one, but only for some users | The reading location has a cached copy, or a cached negative lookup, from before the write | Wait out the 60-second window, or lower `cacheTtl`, or read from the authority instead of KV on the path that needs freshness |
| A key you just created reads as `null` in one region | Negative lookups are cached the same as values | Do not pre-read a key before writing it. If a probe is unavoidable, treat `null` as unknown, not absent |
| `handler.get` throws a **414**, and the framework's `notFound()` never runs | Assembled key exceeded 512 bytes | Budget for the prefix, keep short keys verbatim, hash the overflow |
| Writes silently stop landing on one key | 1 write per second per key, free and paid | Spread across discrete keys, or move that key to a Durable Object |
| The bill is dominated by an operation nobody thought about | Writes are $5.00 / million against reads at $0.50 | Put the Cache API in front so writes are bounded by cache expiries, not by traffic |
| Two processes both believe they hold the lock | `get()` then `put()` is not atomic; last write wins with no error | Move the lock to D1 `INSERT OR IGNORE` or a Durable Object |
| Free plan stops accepting writes mid-afternoon | 1,000 writes/day, reset 00:00 UTC | Batch, throttle behind a cache, or move to the paid plan |
| `expirationTtl: 30` rejected | Minimum is 60 seconds | Store the intended expiry inside the value and check it on read |

## Measured on this account today

Five measurements taken against the live namespace bound as `KV` in `wrangler.toml`. Account id and namespace ids are redacted below; substitute your own. The consistency probe wrote two obviously-named temporary keys, `tmp_consistency_probe_20260725` and `tmp_consistency_probe_b_20260725`, and both were deleted afterwards and verified gone (HTTP 404).

**1. Namespaces on the account — 6.**

```
npx wrangler kv namespace list
```

**2. Keys in the production namespace — 6,773, of which 6,568 are page snapshots.**

```
npx wrangler kv key list --namespace-id <NAMESPACE_ID> --remote > keys.json
python3 -c "import json;d=json.load(open('keys.json'));print(len(d))"
```

Prefix breakdown: `lastgood:` 6,568, `sync:` 35, `trail:` 33, `share_use:` 25, `mcp_oauth:` 18, `idem:` 6, then singletons. The longest key name measured **110 bytes** against the 512-byte limit.

**3. Stored bytes — 164.70 MB across the 1,041 snapshot keys that carry size metadata.** `refreshLastGood()` writes `{ts, bytes, ct}` as KV metadata, so `list()` returns the size of every value it wrote without reading any of them.

```
python3 -c "import json;d=json.load(open('keys.json'));b=[k['metadata']['bytes'] for k in d if k.get('metadata',{}).get('bytes')];print(len(b),sum(b),max(b))"
```

Median value 155,154 bytes, largest 2,140,072 bytes — 8% of the 25 MiB ceiling. Extrapolating that mean across all 6,568 snapshot keys puts the namespace at roughly **1.01 GB**, which is the 1 GB included allowance almost exactly; the overage at $0.50/GB-month is about half a cent. Treat the extrapolation as an estimate: the 5,527 older keys without metadata were not measured.

**4. Seven days of real operations — 899,100 reads, 85,620 writes, 740 deletes, 160 lists.** From Cloudflare's GraphQL analytics API, 2026-07-19 to 2026-07-26.

```
POST https://api.cloudflare.com/client/v4/graphql
{"query":"query { viewer { accounts(filter: {accountTag: \"<ACCOUNT_ID>\"}) {
  kvOperationsAdaptiveGroups(limit: 100, filter: {
    datetime_geq: \"2026-07-19T00:00:00Z\", datetime_leq: \"2026-07-26T00:00:00Z\",
    namespaceId: \"<NAMESPACE_ID>\"}) { sum { requests } dimensions { actionType } } } } }"}
```

The arithmetic that matters:

| Operation | 7-day count | Rate | Gross at list rates |
| --- | --- | --- | --- |
| Read | 899,100 | $0.50 / million | $0.4496 |
| Write | 85,620 | $5.00 / million | $0.4281 |
| Delete | 740 | $5.00 / million | $0.0037 |
| List | 160 | $5.00 / million | $0.0008 |
| **Total** | **985,620** | — | **$0.8822** |

Writes are **8.7% of the operations and 48.5% of the gross cost**. Extrapolated to a month: 3.85 million reads against the 10 million included, and 366,943 writes against the 1 million included — so the actual invoice line is **$0.00**. The write allowance is the binding constraint, with 2.7× headroom: 12,231 writes a day today, 33,333 a day before the meter starts.

[[widget:3]]

**5. Write, then read, and time the gap — visible in 0.21 s and 0.30 s across two trials.**

```
# seed the negative lookup at the reading location
for i in $(seq 1 6); do curl -s -o /dev/null -w "%{http_code} " \
  "https://miscsubjects.com/api/kv?key=tmp_consistency_probe_b_20260725" \
  -H "x-terminal-key: $TERMINAL_KEY"; sleep 2; done       # 404 404 404 404 404 404

npx wrangler kv key put tmp_consistency_probe_b_20260725 probe-b \
  --namespace-id <NAMESPACE_ID> --remote                   # real 1.14s

# poll every 0.5s until it appears
for i in $(seq 1 200); do code=$(curl -s -o /tmp/pb.txt -w "%{http_code}" \
  "https://miscsubjects.com/api/kv?key=tmp_consistency_probe_b_20260725" \
  -H "x-terminal-key: $TERMINAL_KEY"); \
  [ "$code" = "200" ] && break; sleep 0.5; done            # t+0.21s VISIBLE probe-b
```

Both trials converged in well under a second, including the trial that deliberately seeded six cached negative lookups first. **This does not demonstrate read-after-write consistency and must not be read as one.** It measures one reading location, close to the writer, twice. The documented window is a worst case, and the reference says explicitly that even same-location visibility "is not guaranteed". A system that happens to converge fast today is not a system you can design against.

Ten repeat reads of the same key through the deployed Worker, end to end over HTTPS from a laptop: minimum 136 ms, median 202 ms, maximum 260 ms. Almost all of that is network round trip, not KV — Cloudflare's own instrumentation puts the 90th percentile of KV Worker invocations "in less than 12 ms", and reports that the hottest 0.03% of keys, which serve over 40% of global KV requests, "resolve in under a millisecond".

An independent benchmark run from Cloudflare's Washington DC location (150 samples per metric, KV through the binding against Upstash Redis over HTTPS, same Worker, same request) put KV's hot read at **2.6 ms p50** — twice as fast as the competitor — and KV's single write at **171.8 ms p50**, twenty-eight times slower. That single pair of numbers is the whole argument of this page in measured form: KV's reads are the best on the platform and its writes are the worst.

For where KV sits among the other bindings in this stack, see [the Cloudflare stack index](/a/cloudflare-os), [Workers as the runtime](/a/cloudflare-os-workers) and [D1 as the relational store](/a/cloudflare-os-d1).

## The next read-only inventory still shows snapshots dominating the namespace

Wrangler 4.103.0 listed the production namespace at `2026-07-26T05:45:59.424Z`. Listing reads namespace metadata; it did not write, delete or fetch any value.

| Fresh check | Result |
| --- | ---: |
| Namespaces on the account | 6 |
| Keys in the production namespace | 6,767 |
| `lastgood:` snapshot keys | 6,568 |
| Longest key name | 110 bytes of the 512-byte limit |
| Keys carrying byte-count metadata | 1,046 |
| Bytes recorded by that metadata | 175,859,336 |
| Largest recorded value | 2,140,072 bytes |

Largest prefix groups: `lastgood:` 6,568 · `(singleton)` 54 · `sync:` 35 · `trail:` 33 · `share_use:` 25 · `mcp_oauth:` 18. The inventory reproduces the architectural claim directly: 97% of all keys are regenerable `lastgood:` page snapshots, not transactional state.

Run the same inventory without exposing the namespace id in a transcript:

```bash
npx wrangler kv namespace list
npx wrangler kv key list --namespace-id <NAMESPACE_ID> --remote > keys.json
python3 -c "import json; d=json.load(open('keys.json')); print(len(d), max(len(k['name'].encode()) for k in d))"
```

The first number is the key count. The second is the longest key name in bytes.

## Sources

1. How Workers KV works — https://developers.cloudflare.com/kv/concepts/how-kv-works/
2. Read key-value pairs — https://developers.cloudflare.com/kv/api/read-key-value-pairs/
3. Write key-value pairs — https://developers.cloudflare.com/kv/api/write-key-value-pairs/
4. Workers KV limits — https://developers.cloudflare.com/kv/platform/limits/
5. Workers KV pricing — https://developers.cloudflare.com/kv/platform/pricing/
6. Workers Cache API — https://developers.cloudflare.com/workers/runtime-apis/cache/
7. Workers storage options — https://developers.cloudflare.com/workers/platform/storage-options/
8. Cloudflare's Workers KV latency measurements — https://blog.cloudflare.com/faster-workers-kv/
9. vinext fix for KV's 512-byte cache-key limit — https://github.com/cloudflare/vinext/pull/2606
10. Cloudflare documentation clarification for KV consistency — https://github.com/cloudflare/cloudflare-docs/pull/2678
11. Upstash Redis versus Cloudflare KV benchmark — https://upstash.com/blog/upstash-redis-vs-cloudflare-kv
12. OAuth for all — https://news.ycombinator.com/item?id=48672342
13. A bit of math around Cloudflare's R2 pricing model — https://news.ycombinator.com/item?id=28703233
14. Launch HN: Fly.io (YC W20) – Deploy app servers close to your users — https://news.ycombinator.com/item?id=22644115
15. Reality Check for Cloudflare Wasm Workers and Rust — https://news.ycombinator.com/item?id=28581040
16. Durable Object alarm loop: $34k in 8 days, zero users, no platform warning — https://news.ycombinator.com/item?id=47917107
17. Show HN: An edge first feature flag implementation on Cloudflare — https://news.ycombinator.com/item?id=42531229
18. Fresh first-party KV namespace inventory — https://miscsubjects.com/api/articles/cloudflare-os-kv
19. First-party seven-day KV operations receipt — https://miscsubjects.com/api/articles/cloudflare-os-kv
20. First-party KV visibility probe — https://miscsubjects.com/api/articles/cloudflare-os-kv
21. First-party KV-lock code audit — https://miscsubjects.com/api/articles/cloudflare-os-kv
22. First-party snapshot metadata inventory — https://miscsubjects.com/api/articles/cloudflare-os-kv


---

# D1 bills rows, not queries, and serial round trips decide the architecture

slug: cloudflare-os-d1 · https://miscsubjects.com/a/cloudflare-os-d1 · tags: cloudflare, architecture, d1, cloudflare-os, sqlite, database, durable-objects, performance, migrations · updated 2026-07-26T03:59:21.950Z

D1 is Cloudflare's managed SQLite. Bind a database to a Worker in `wrangler.toml`, get `env.DB`, write ordinary SQL. No connection string, no pool, no instance to size. That pitch is accurate.

The shape underneath is what decides whether you should build on it: a single SQLite file inside a single Durable Object in a single Cloudflare location, billed by the row rather than by the query, capped at 10 GB per database and 2,000,000 bytes per stored value. Every surprise below follows from one of those four facts.

Siblings: [the platform index](/a/cloudflare-os), [Workers and Durable Objects](/a/cloudflare-os-workers), [R2 for the fields that do not fit](/a/cloudflare-os-r2).

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Cloudflare's own Workers architect calls D1 a wrapper

Kenton Varda, who built the Workers runtime, wrote this on Hacker News in June 2026:

> I'll let you in on a sort of dirty secret:
>
> It's almost always better to use Durable Objects storage, rather than D1. Even if you only want a single global database, it's better to implement that as a singleton Durable Object, than by using D1. Because that's all D1 itself actually is: a singleton Durable Object that exposes an API to its SQLite database. It's just a wrapper.

His decision rule, in the same comment:

> If your app does no more than one DB query per request, then D1 is fine: the Worker runs near the end user, and talks over the long-haul network to D1 just once. Whereas with Durable Objects, your Worker would talk over the long-haul network to the Durable Object. No difference.
>
> But if your app ever does two or more queries in series for a single request, then Durable Objects becomes vastly better, because you get to move that query-chaining code to happen directly where the database lives, rather than have multiple round trips.

And the reason D1 exists at all: "Really, though, the only reason D1 exists is for comfort. Once you know how to use Durable Objects, there's no reason to use D1." He names one exception — D1's read replication is not yet available to raw Durable Objects.

A **Durable Object** is a single-instance JavaScript class with private storage, addressed by name, that Cloudflare guarantees exists exactly once globally. A SQLite-backed one carries its own embedded SQLite database with the same SQL limits as D1, and your code runs in the same process as it — `sql.exec()` is a local function call, not a network request. That is the entire latency difference.

**Verdict.** Choose D1 when all three hold: the data is one global relational set, the request path makes one or two queries, and you want the operational surface D1 has and raw Durable Objects do not — `wrangler d1 execute` against production, versioned migration files, Time Travel point-in-time restore, and read replicas. Choose a SQLite Durable Object when the data partitions naturally per user, tenant, room or document, or when a single request chains three or more dependent queries. Those two rules cover almost every case; when they conflict, the query-chaining rule wins, because round trips are the thing you cannot optimise away later.

## The limits, fetched today, are the actual specification

Every number below is from `https://developers.cloudflare.com/d1/platform/limits/`, last updated 21 April 2026 per the page itself.

| Limit | Workers Paid | Workers Free |
| --- | --- | --- |
| Databases per account | 50,000 (raisable by request) | 10 |
| Maximum database size | 10 GB — cannot be raised | 500 MB |
| Maximum storage per account | 1 TB (raisable by request) | 5 GB |
| Time Travel window | 30 days | 7 days |
| Queries per Worker invocation | 1,000 | 50 |
| Columns per table | 100 | 100 |
| Rows per table | Unlimited within the size cap | Unlimited within the size cap |
| Maximum string, BLOB or table row size | **2,000,000 bytes** | 2,000,000 bytes |
| Maximum SQL statement length | **100,000 bytes** | 100,000 bytes |
| Maximum bound parameters per query | **100** | 100 |
| Maximum arguments per SQL function | 32 | 32 |
| Bytes in a `LIKE` or `GLOB` pattern | 50 | 50 |
| Maximum SQL query duration | 30 seconds | 30 seconds |
| Simultaneous D1 connections per Worker invocation | 6 | 6 |
| Rows read included | 25 billion / month, then $0.001 per million | 5 million / day, hard stop |
| Rows written included | 50 million / month, then $1.00 per million | 100,000 / day, hard stop |
| Storage included | 5 GB, then $0.75 per GB-month | 5 GB total |

Two of these get their own sections below: the 2,000,000-byte value cap, and rows as the billing unit. Three more matter immediately. **The 10 GB cap cannot be raised** — the docs say so in a caution box. **Each database is single-threaded**, so throughput is `1 / average query duration`: 1 ms queries give roughly 1,000 per second, 100 ms queries give 10. **Batch limits apply per statement**, not per batch, so a `db.batch()` of 40 statements can carry 40 × 100 KB of SQL.

## One undocumented ceiling: five terms in a compound SELECT

Building a table inventory with `SELECT 'x' t, COUNT(*) n FROM x UNION ALL …` across 89 tables failed immediately:

```
too many terms in compound SELECT: SQLITE_ERROR [code: 7500]
```

Bisecting against production found the number. Five `UNION ALL` terms succeed. Six fail.

```bash
# 5 terms — succeeds.  6 terms — SQLITE_ERROR 7500.
npx wrangler d1 execute <DB_NAME> --remote \
  --command "SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1"
```

Upstream SQLite defaults `SQLITE_MAX_COMPOUND_SELECT` to 500. D1 answers at 5, and this is not on the limits page. If anything generates SQL for you — an ORM, a reporting layer, a tool emitting multi-row `VALUES` as unions — chunk at five.

`dbstat`, the virtual table that reports per-table page usage, is also compiled out: `no such table: dbstat: SQLITE_ERROR [code: 7500]`. Per-table size has to be estimated with `SUM(LENGTH(col))`.

## Rows are the billing unit, and an unindexed predicate bills the whole table

You are not billed per query. You are billed for **rows read** — every row the engine had to scan, not the rows it returned. This is the most expensive misunderstanding available on D1.

The pricing page states it without hedging: a full scan of a 5,000-row table counts as 5,000 rows read, and "A query that filters on an unindexed column may return fewer rows to your Worker, but is still required to read (scan) more rows to determine which subset to return." Row size is irrelevant — "A row that is 1 KB and a row that is 100 KB both count as one row."

Rows written are simpler: `INSERT`, `UPDATE` and `DELETE` each cost one written row per row affected, and **an index adds a second written row** whenever the indexed column is part of the write.

### Where to see the number

Every D1 result carries a `meta` object. Read `meta.rows_read` and `meta.rows_written` in your Worker:

```js
const res = await env.DB.prepare("SELECT * FROM articles WHERE title = ?1")
  .bind(title).all();
console.log(res.meta.rows_read, res.meta.rows_written, res.meta.duration);
```

From the CLI, `--json` prints the same object:

```bash
npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT COUNT(*) FROM articles WHERE title = 'D1 as the spine'"
```

Across the account it is in the Cloudflare dashboard at **your D1 database → Metrics → Row Metrics**, and in the GraphQL Analytics API.

### The same query, measured with and without an index

Run against a scratch table of 50,000 rows in this build's preview database, so nothing production was touched. Commands are in the measurement section at the bottom.

| Step | Result | `rows_read` | `rows_written` | Duration |
| --- | --- | --- | --- | --- |
| `SELECT COUNT(*) FROM d1_bench WHERE tenant = 'tenant-42'` — no index | 94 | **50,000** | 0 | 5.7362 ms |
| `CREATE INDEX d1_bench_tenant_idx ON d1_bench (tenant)` | — | 100,442 | 50,001 | 31.6053 ms |
| The identical `SELECT` again — index present | 94 | **95** | 0 | 0.2432 ms |
| `INSERT INTO d1_bench (tenant, payload) VALUES ('tenant-42','x')` | — | 0 | **2** | 0.28 ms |

Same query, same answer, 526 times fewer rows read and 23.6 times faster. The last row is the index's cost made visible: one insert now writes two rows, one to the table and one to the index, exactly as the pricing page says.

Production shows the same shape. `articles` has 2,186 rows and `slug` as its primary key:

| Query on `articles` (2,186 rows) | Result | `rows_read` | Duration |
| --- | --- | --- | --- |
| `WHERE slug = 'cloudflare-os-d1'` (indexed primary key) | 1 | **1** | 0.2003 ms |
| `WHERE title = 'D1 as the spine: two SQL databases, one of them append-only'` | 1 | **2,186** | 5.8578 ms |

On the largest table, `turn_costs` at 135,229 rows and indexed on `ts` only, one equality filter on the unindexed `key` column read **135,247 rows in 140.4919 ms** to return a count of 2,817.

### The arithmetic

Rows read: $0.001 per million after 25 billion included per month. Take the 50,000-row scan at one query per second — a modest API endpoint.

```
86,400 queries/day × 50,000 rows      = 4,320,000,000 rows read/day
4,320,000,000 × 30                    = 129,600,000,000 rows read/month
129,600,000,000 − 25,000,000,000 incl = 104,600,000,000 billable
104,600 millions × $0.001             = $104.60 / month
```

The indexed version of the identical query:

```
86,400 queries/day × 95 rows          = 8,208,000 rows read/day
8,208,000 × 30                        = 246,240,000 rows read/month
246,240,000 < 25,000,000,000 included = $0.00 / month
```

One `CREATE INDEX` is the difference between $104.60 and nothing. Its one-time write cost was 50,001 rows written — five cents at $1.00 per million.

On the free plan the same comparison is not a bill, it is an outage: 5,000,000 rows read per day, so **100 queries per day** at 50,000 rows each before D1 starts returning errors, against 52,631 at 95 rows each.

At the top end this is real money. A solo founder posted a postmortem in April 2026 after a Durable Object alarm loop — same rows-read meter — peaked at roughly 930 billion row reads per day and produced a $34,895 invoice with zero users:

> My DO agent's onStart() handler called this.ctx.storage.setAlarm() on every wake-up without checking whether an alarm was already scheduled.

No platform warning fired. Set a Cloudflare billing alert before you set anything else.

## SQLITE_TOOBIG is the 2,000,000-byte value cap, and serialization gets you there first

The exact string D1 surfaces is `D1_ERROR: string or blob too big`, with the underlying SQLite constant `SQLITE_TOOBIG`. It fires when any single string, BLOB or table row being written exceeds 2,000,000 bytes. It is not a database-size error and not a statement-length error — those have their own messages.

Two things make it arrive earlier than expected. The 100,000-byte statement cap means a large value can blow the statement before it blows the row. And the ceiling can be reached through serialization rather than raw size. A minimal reproduction filed against `cloudflare/workers-sdk` in May 2026:

> Workflows (local `wrangler dev`): a ~200 KB `Uint8Array` step output fails with `string or blob too big: SQLITE_TOOBIG`, but the same bytes as an `ArrayBuffer` (or a 2 MB string) succeed

200 KB of bytes failing while 2 MB of string succeeds is the tell: what is measured is the serialized representation, not your data.

### A worked example, with the numbers

The `articles` table stores each body as `TEXT` and everything non-scalar — claims, sources, widgets, the append-only revision chain — as one JSON `meta` column. One row broke.

`cognitive-stack-intro` carried 94 claims and 90 sources plus 24 inline revision snapshots, each holding a full copy of the body, claims and sources at that point in time. Stored `meta` reached **2,068,258 bytes**, 68,258 over the cap. Every write to that row — repair, claim, fill-slots — returned HTTP 500 with `D1_ERROR: string or blob too big`. Readable, permanently unwritable.

Three options, and only three:

| Option | What it does | Cost | When it is right |
| --- | --- | --- | --- |
| **Merge** | Fold the row into a related row and redirect | Loses the row's identity and its URL | The row was a near-duplicate anyway |
| **Prune** | Delete the least valuable fields until under 2 MB | Loses data permanently; the cap comes back as the row grows | The excess is genuinely junk and growth has stopped |
| **Offload to R2** | Move the heavy fields to object storage, keep a pointer plus a hash in D1 | One extra fetch when the heavy field is actually read | The data must be kept and the row keeps growing — the general answer |

Offload won, because pruning an append-only chain is the one thing the chain exists to prevent. `functions/_lib/revisions_r2.js` writes each full snapshot to R2 at `revisions/<slug>/<n>.json` and leaves a slim index entry in D1 carrying `n`, `ts`, `title`, `bytes`, `prev_hash`, `hash` and `r2_key`. Hash-chain verification still runs from D1 alone; the heavy content is fetched only when a specific revision is requested. `migrateRevisions()` runs at the top of every write, so any write heals a bloated row before adding to it.

`meta` fell from 2,068,258 bytes to 206,362 and the row returned HTTP 200. Measured again today it holds 1,990 bytes of body and 267,379 bytes of `meta` while carrying 80 claims, 112 sources and 43 revisions — more content than the version that could not be saved, in an eighth of the space. The object side is in [R2 as the place large fields go](/a/cloudflare-os-r2).

The same cap caught a bulk import from the other direction. Loading 663,115 iMessage rows hit `SQLITE_TOOBIG` on the **statement** cap rather than the row cap, because the importer packed many rows into one `INSERT`. Fix: byte-aware batching at ≤80,000 bytes per statement, plus a 20,000-character cap on any single message body after one arrived at 123 KB.

**The rule from both cases:** any column whose size is a function of history rather than of the schema belongs in R2 with a pointer in D1 — revision chains, audit payloads, uploaded documents, model transcripts. Keep the hash in D1 so the pointer is verifiable.

The largest row still in the table is 692,724 bytes of body plus 821,837 bytes of `meta` — **1,514,561 bytes, 76% of the cap**. It will need the same treatment.

## A transaction cannot span two requests, and the workaround is a deliberate parse error

D1 runs in auto-commit. The Workers Binding API documentation is plain: `batch()` "Sends multiple SQL statements inside a single call to the database… D1 operates in auto-commit. Our implementation guarantees that each statement in the list will execute and commit, sequentially, non-concurrently." Batched statements are a transaction — "If a statement in the sequence fails, then an error is returned for that specific statement, and it aborts or rolls back the entire sequence."

What is unavailable is holding a transaction open across two round trips: read, decide in JavaScript, write atomically against the state you read. An operator hit exactly this in April 2025:

> Another fun limitation is that a transaction cannot span multiple D1 requests, so you can't select from the database, execute application logic, and then write to the database in an atomic way. At most, you can combine multiple statements into a single batch request that is executed atomically.
>
> When I needed to ensure atomicity in such a multi-part "transaction", I ended up making a batch request, where the first statement in the batch checks a precondition and forces a JSON parsing error if the precondition is not met, aborting the rest of the batch statements.

The statement they used:

```sql
SELECT
  IIF(<precondition>, 1, json_extract("inconsistent", "$")) AS consistent
FROM ...
```

If the precondition holds, the statement returns 1. If not, `json_extract` is handed the invalid JSON literal `inconsistent`, throws, and the batch aborts before any write lands. Their own limit on it: "For anything more complex, one would probably need to create tables to store temporary values, and translate a lot of application logic into SQL statements to achieve atomicity."

The three honest options, ranked:

1. **Push the condition into SQL and use `batch()`.** Works when the precondition fits a `WHERE` or a `CASE`. Prefer `UPDATE … WHERE version = ?` over a poison-pill parse error: optimistic concurrency with a version column is the same guarantee written on purpose, and it reports failure as `changes: 0` rather than by throwing.
2. **Move the entity into a Durable Object.** Single-threaded by construction, so read-decide-write inside one method is atomic with no ceremony. This is where the constraint is pushing you.
3. **Use a database with real interactive transactions**, reached through Hyperdrive. Correct when the logic genuinely cannot be expressed in one round trip.

## Latency: two production reports, both true, measuring different things

The negative reports are specific and repeated. From someone running D1 in production across multiple projects for over a year:

> Using D1 in production for over an year on multiple projects - I can confirm response times to simple queries regularly take 400ms and beyond. On top there's constant network, connection and a plethora of internal errors.

From an evaluation that ended in rejection, with the comparison numbers:

> Using CF Workers + DigitalOcean Postgres, I was seeing query responses in the 50-100ms range.
>
> Using CF Workers + CF D1, I was seeing query responses in the 300-3000ms range.
>
> Both workers had Smart Placement enabled.

From a production user in April 2026, on reliability rather than latency:

> D1 reliability has been bad in our experience. We've had queries hanging on their internal network layer for several seconds, sometimes double digits over extended periods (on the order of weeks).

Against all of that, in the same thread as the 400 ms report:

> I am running 2 production apps on Cloudflare workers, both using D1 for primary storage. I found the performance ok, especially after enabling Smart Placement [1].

Neither side is wrong. They differ on two variables: how many D1 calls a single request makes, and whether the Worker ended up near the database.

**Smart Placement** is the mechanism in the middle. By default a Worker runs in the data centre nearest the user, which is the worst place to be if it then makes several round trips to a database in one fixed location. Smart Placement analyses a Worker's traffic and moves execution close to the backend instead. The documentation is precise about its boundaries: it takes up to 15 minutes to analyse a Worker after deployment; it needs consistent traffic from multiple locations to decide anything; it "only considers locations where the Worker has previously run", so it cannot place a Worker somewhere that never receives traffic; and it reverts itself when it makes things slower, which the docs put at fewer than 1% of Workers. Enable it in `wrangler.toml`:

```toml
[placement]
mode = "smart"
```

Its ceiling, from Varda in the same thread: "even if you have the Worker running in the same colo or even same machine as the D1 database, you're still speaking a network protocol to talk to it, serializing and deserializing data, switch contexts, etc. Directly invoking SQLite locally will still be orders of magnitude faster."

**Verdict.** Budget one long-haul round trip per D1 call, from wherever the Worker runs to wherever the database lives. A request making one query pays one, and Smart Placement will not help it — moving the Worker to the database just moves the same hop to the other end. A request making six sequential queries pays six, and that is where the 400 ms and 3-second numbers come from. Smart Placement collapses those six, which is the difference between the negative reports and the positive one. If the path is inherently chatty and cannot be flattened into one `batch()`, stop tuning D1 and move the entity into a Durable Object, where the queries stop crossing a network at all.

Two mitigations before concluding D1 is too slow. **Read replication** puts read-only copies in other regions, used through the Sessions API — `env.DB.withSession()` — which attaches a bookmark to each query so a session keeps sequential consistency even when different replicas serve it. Replicas cost nothing extra; you pay the same `rows_read`. Without the Sessions API it does nothing: "otherwise all queries will continue to be executed only by the primary database." **Caching** is the other: on this build most article reads never reach D1, because an edge cache or a KV snapshot answers first — [KV as the fast lane](/a/cloudflare-os-kv).

## Per-tenant sharding is documented, and impractical for the reason nobody mentions

Cloudflare's limits FAQ recommends the pattern: "D1 is designed for horizontal scale out across multiple, smaller (10 GB) databases, such as per-user, per-tenant or per-entity databases." 50,000 databases per account on the paid plan, raisable into the millions.

The count is not the problem. A Worker can only talk to a database bound to it at deploy time:

> It's not possible to set up per-user data in D1. Like in theory you probably could, but the DX infrastructure to make it possible is non-existent - you have to explicitly bind each database into your worker. At best you could try to manually shard data but that has a lot of drawbacks. Or maybe have the worker republish itself whenever a new user is registered? That seems super dangerous and unlikely to work in a concurrent fashion […] When I asked on Discord, someone from Cloudflare confirmed that DO is indeed the only way to do tenancy-based sharding

The limits page gives the hard number: bindings are roughly 150 bytes each inside a 1 MB script-metadata budget, so "approximately 5,000" D1 bindings per Worker script. The documented 50,000 databases and the reachable 5,000 are ten times apart, and every new tenant needs a redeploy.

**What to do instead.** Durable Objects address instances by name at runtime — `env.MY_DO.idFromName(tenantId)` — one binding for the class, unlimited instances behind it, each with its own 10 GB SQLite database and no per-class storage cap. Tenancy sharding without a deploy. Someone running it at scale, July 2026:

> We serve multi million MAU on sqlite orchestrated through durable objects. It's not the most complex thing in the world but it goes further than CRUD. It costs us such a small amount of money for what it does.

If you must stay on D1: bind a fixed number of databases up front and hash tenants into them, accepting that rebalancing means a migration. Dynamic database-per-tenant does not exist on D1 today.

## Migrations are ordered files, and `d1 execute` silently desynchronises them

Migrations are `.sql` files in `migrations/`, named with a leading sequence number and applied in filename order. Wrangler records what it applied in a `d1_migrations` table inside the database.

```bash
# 1. Create an empty, correctly-numbered file. Prints the path it created.
npx wrangler d1 migrations create loop-content-spine "add_tenant_index"
#    -> migrations/0331_add_tenant_index.sql

# 2. Write the SQL into that file. Forward-only; write it to be re-runnable.
#    CREATE INDEX IF NOT EXISTS idx_articles_register ON articles(register);

# 3. See exactly what would run, before it runs.
npx wrangler d1 migrations list loop-content-spine --remote

# 4. Apply to the preview database first.
npx wrangler d1 migrations apply loop-content-spine-preview --remote

# 5. Then production.
npx wrangler d1 migrations apply loop-content-spine --remote
```

Use the **database name**, not the binding name. The docs give the reason: "the binding name can change, whereas the database name cannot."

**There is no `down` migration.** The system supports create, list and apply — nothing else. Rolling back means one of two things:

```bash
# Option A — a forward migration that undoes the change. Preferred.
npx wrangler d1 migrations create loop-content-spine "drop_tenant_index"

# Option B — Time Travel, point-in-time restore, 30 days on Workers Paid.
npx wrangler d1 time-travel info loop-content-spine
# ⚠️ The current bookmark is '0000110b-000002cc-000050b4-90fa940d708157e29a40c704f1591c8e'
npx wrangler d1 time-travel restore loop-content-spine --bookmark=<BOOKMARK>
# or:  --timestamp=2026-07-25T00:00:00Z
```

Take the bookmark **before** you apply, not after you break something. Time Travel restores the whole database, so it is a blunt instrument for one bad table.

The failure mode this repository demonstrates is drift. There are 330 `.sql` files in `migrations/`. `d1_migrations` records 136 applied, most recently `0133_charlie_audit.sql`. `wrangler d1 migrations list` therefore reports 202 still to be applied — and nearly all of them already are, because those schema changes were pushed with `wrangler d1 execute --command "CREATE TABLE …"` instead of through the runner. Wrangler cannot know that. Running `apply` now would replay 202 files against a schema that already has them.

**How to avoid it:** never change schema with `d1 execute`. If you already have, insert the missing filenames into `d1_migrations` so the ledger matches reality, then resume using `apply`. Check they agree before every release:

```bash
ls migrations/*.sql | wc -l
npx wrangler d1 execute loop-content-spine --remote \
  --command "SELECT COUNT(*) applied, MAX(name) latest FROM d1_migrations"
```

## Choosing between D1 and the three things it competes with

| | D1 | Durable Object + SQLite | Hyperdrive → Postgres/MySQL | Hosted database, direct |
| --- | --- | --- | --- | --- |
| What it is | Managed SQLite in one location, exposed over the network | Your code and an embedded SQLite file in the same process | Connection pooling and caching in front of your own regional database | A normal database reached over the internet |
| Query latency from a Worker | One long-haul round trip per call | Effectively zero once you are in the object | One round trip to the pooled connection, warm | Full connection setup plus round trip |
| Multi-query request | Pays N round trips; needs Smart Placement | Pays one hop total, then local calls | Pays N round trips but keeps the connection | Worst case |
| Transactions across app logic | No | Yes, single-threaded by construction | Yes, full interactive transactions | Yes |
| Per-tenant sharding | Not practically — bindings are static | Yes, `idFromName()` at runtime | Via your own schema | Via your own schema |
| Size ceiling | 10 GB per database, hard | 10 GB per object, unlimited objects | Whatever your database does | Whatever your database does |
| Read replicas | Yes, via the Sessions API | Not yet | Your database's own replicas | Your database's own replicas |
| Billing unit | Rows read and written | Rows read and written, plus object duration | Workers time; the database is billed separately | Database bill plus egress |
| Operational surface | `wrangler d1 execute`, migrations, Time Travel | You build it | Your existing tooling, unchanged | Your existing tooling |
| **Verdict** | One global relational set under 10 GB, ≤2 queries per request, and you want the CLI and migrations | Anything per-entity, or any chatty request path | You already have Postgres or MySQL and are not leaving it | Only if Hyperdrive cannot reach it |

Two mistakes to avoid: reaching for D1 because it is the dashboard default when the data is obviously per-user, and leaving Cloudflare over D1 latency when the fix was one binding change.

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| `D1_ERROR: string or blob too big` | A single value or row exceeds 2,000,000 bytes | Offload the heavy field to R2 and keep a pointer plus hash in D1 |
| `string or blob too big` on a bulk insert | The statement, not the row, exceeded 100,000 bytes | Byte-aware batching; cap each statement at ~80,000 bytes |
| `too many terms in compound SELECT: SQLITE_ERROR [code: 7500]` | More than 5 `UNION`/`UNION ALL` terms in one statement | Chunk generated SQL into groups of five |
| `no such table: dbstat` | The `dbstat` virtual table is not compiled into D1 | Estimate table size with `SUM(LENGTH(col))` |
| `D1 DB is overloaded. Requests queued for too long.` | Queries are slow and the single-threaded database has a full queue | Index the predicates; shorten each query; spread load; shard |
| `D1 DB is overloaded. Too many requests queued.` | Request rate exceeds `1 / query duration` | Same, plus read replicas via the Sessions API for read-heavy load |
| `Exceeded maximum DB size.` | The database passed 10 GB, which cannot be raised | Delete rows, or shard across databases |
| `Your account has exceeded D1's maximum account storage limit…` | All databases together passed the account cap | Delete unused databases or raise the account limit by request |
| `D1 DB exceeded its CPU time limit and was reset.` | One query scanned far too much — a huge table scan or a bulk import | Split into smaller shards; index the predicate |
| `D1 DB storage operation exceeded timeout which caused object to be reset.` | A single write touched gigabytes | Batch the write into chunks of ~1,000 rows |
| `D1 DB reset because its code was updated.` | Cloudflare restarted the Durable Object backing your database | Retry — it is transient and expected. Make writes idempotent |
| `Network connection lost.` / `Cannot resolve D1 DB due to transient issue on remote node.` | Transient network fault between Worker and database | Retry, but only if the query is idempotent |
| `D1_TYPE_ERROR` | A bound parameter was `undefined` | D1 does not accept `undefined`. Coerce to `null` |
| Bill far higher than query volume suggests | Unindexed predicates scanning whole tables | Read `meta.rows_read`; add an index; recheck |
| Queries fine locally, slow in production | Worker running near the user, database elsewhere, several round trips | Enable Smart Placement, or flatten into one `batch()`, or move to a Durable Object |

## Every measurement on this page, and the command that produced it

Taken 25 July 2026 against this build's production D1 databases with wrangler 4.103.0. Account id and database ids redacted; substitute your own database name. Reads only, except the scratch table, created and dropped in the **preview** database.

**1. Table inventory — 89 tables, 243,173 rows.** The five-term compound-SELECT ceiling forces chunks of five:

```bash
npx wrangler d1 execute <DB_NAME> --remote --json \
  --command "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"

npx wrangler d1 execute <DB_NAME> --remote --json --command \
"SELECT 'turn_costs' t, COUNT(*) n FROM turn_costs UNION ALL SELECT 'log' t, COUNT(*) n FROM log UNION ALL SELECT 'imessages' t, COUNT(*) n FROM imessages UNION ALL SELECT 'leads' t, COUNT(*) n FROM leads UNION ALL SELECT 'articles' t, COUNT(*) n FROM articles"
```

Largest first: `turn_costs` 135,229 · `log` 59,164 · `imessages` 10,536 · `leads` 10,089 · `agent_turns` 6,844 · `tasks` 6,055 · `cc_turns` 2,296 · `articles` 2,186 · `pipeline` 2,058 · `directory` 892. Six tables are empty.

**2. Database size — 281,993,216 bytes on the content database, 1,062,027,264 bytes on the event log.** `meta.size_after` is returned on every query, so any read gives it:

```bash
npx wrangler d1 execute <DB_NAME> --remote --json --command "SELECT 1"   # read meta.size_after
npx wrangler d1 execute <LEDGER_NAME> --remote --json --command "SELECT COUNT(*) FROM events"
```

The event log holds 400,907 rows at 1.062 GB — 10.6% of the 10 GB per-database ceiling and already past the 500 MB the free plan allows. Both databases together are 1.25 GB, inside the 5 GB included, so storage costs $0.00.

**3. Largest table by stored bytes — `articles`, 78,057,031 bytes.** `dbstat` is unavailable, so size is summed from the columns:

```bash
npx wrangler d1 execute <DB_NAME> --remote --json \
  --command "SELECT SUM(LENGTH(COALESCE(body,''))+LENGTH(COALESCE(meta,''))) AS bytes FROM articles"

npx wrangler d1 execute <DB_NAME> --remote --json \
  --command "SELECT slug, LENGTH(COALESCE(body,'')) body_bytes, LENGTH(COALESCE(meta,'')) meta_bytes FROM articles ORDER BY (LENGTH(COALESCE(body,''))+LENGTH(COALESCE(meta,''))) DESC LIMIT 5"
```

The second query read 4,372 rows to sort 2,186 — an unindexed sort reads the table twice. Largest row: 692,724 + 821,837 = 1,514,561 bytes.

**4. Indexed versus unindexed, identical query.** Against the preview database only:

```bash
DB=<PREVIEW_DB_NAME>
npx wrangler d1 execute $DB --remote --command \
  "CREATE TABLE d1_bench (id INTEGER PRIMARY KEY, tenant TEXT NOT NULL, payload TEXT)"

npx wrangler d1 execute $DB --remote --command \
"INSERT INTO d1_bench (tenant, payload) SELECT 'tenant-' || (abs(random()) % 500), hex(randomblob(32)) FROM (WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c WHERE x < 50000) SELECT x FROM c)"

npx wrangler d1 execute $DB --remote --json --command \
  "SELECT COUNT(*) AS n FROM d1_bench WHERE tenant = 'tenant-42'"        # rows_read 50000, 5.7362 ms

npx wrangler d1 execute $DB --remote --json --command \
  "CREATE INDEX d1_bench_tenant_idx ON d1_bench (tenant)"                # rows_written 50001

npx wrangler d1 execute $DB --remote --json --command \
  "SELECT COUNT(*) AS n FROM d1_bench WHERE tenant = 'tenant-42'"        # rows_read 95, 0.2432 ms

npx wrangler d1 execute $DB --remote --command "DROP TABLE d1_bench"
```

The recursive CTE is how you generate N rows in one statement without passing the 100,000-byte statement cap.

**5. The compound-SELECT ceiling.** Bisected with `SELECT 1 UNION ALL …` at 2, 5, 6, 8, 10, 15 and 20 terms. 2 and 5 succeed; 6 and above return `SQLITE_ERROR [code: 7500]`.

**6. Migration drift.** `ls migrations/*.sql | wc -l` → 330. `SELECT COUNT(*) applied, MAX(name) latest FROM d1_migrations` → 136, `0133_charlie_audit.sql`. `npx wrangler d1 migrations list <DB_NAME> --remote` → 202 listed as to be applied.

## A fresh read-only receipt reproduces the row meter and the five-term ceiling

Wrangler 4.103.0 ran seven read-only statements against the two production databases at `2026-07-26T05:38:25.854Z`. No table or row changed.

| Check | Result | `rows_read` | SQL duration |
| --- | --- | ---: | ---: |
| `SELECT COUNT(*) FROM articles` | 2,186 articles | 2,186 | 0.1973 ms |
| Primary-key lookup for `cloudflare-os-d1` | 1 row | 1 | 0.1485 ms |
| Equality lookup on the unindexed old title | 1 row | 2,186 | 5.8711 ms |
| Five `UNION ALL` terms | HTTP/API success; 5 rows returned | 0 | 0.1638 ms |
| Six `UNION ALL` terms | `too many terms in compound SELECT: SQLITE_ERROR [code: 7500]` | — | — |
| `SELECT COUNT(*) FROM events` | 401,112 events; database size 1,062,916,096 bytes | 401,112 | 6.8717 ms |
| Migration ledger | 136 applied; latest `0133_charlie_audit.sql` | 136 | 3.2203 ms |

Run the same harmless checks with your database names:

```bash
npx wrangler d1 execute <DB_NAME> --remote --json   --command "SELECT COUNT(*) AS articles FROM articles"

npx wrangler d1 execute <DB_NAME> --remote --json   --command "SELECT slug FROM articles WHERE slug='cloudflare-os-d1'"

npx wrangler d1 execute <DB_NAME> --remote --json   --command "SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1"

npx wrangler d1 execute <DB_NAME> --remote --json   --command "SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1"
```

The fifth query is expected to fail. That failure is the measurement: the same database accepted five compound terms and rejected six with code 7500.

## Sources

1. Cloudflare D1 overview — https://developers.cloudflare.com/d1/
2. D1 limits — https://developers.cloudflare.com/d1/platform/limits/
3. D1 pricing — https://developers.cloudflare.com/d1/platform/pricing/
4. D1Database API — https://developers.cloudflare.com/d1/worker-api/d1-database/
5. Debug D1 — https://developers.cloudflare.com/d1/observability/debug-d1/
6. Use indexes — https://developers.cloudflare.com/d1/best-practices/use-indexes/
7. Use read replication — https://developers.cloudflare.com/d1/best-practices/read-replication/
8. Smart Placement — https://developers.cloudflare.com/workers/configuration/smart-placement/
9. D1 migrations — https://developers.cloudflare.com/d1/reference/migrations/
10. D1 Time Travel — https://developers.cloudflare.com/d1/reference/time-travel/
11. SQLite-backed Durable Objects — https://developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-storage/
12. Cloudflare Hyperdrive — https://developers.cloudflare.com/hyperdrive/
13. Cloudflare workers-sdk — https://github.com/cloudflare/workers-sdk
14. Independent Workers database latency comparison — https://news.ycombinator.com/item?id=43607264
15. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43607561
16. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43607264
17. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43614249
18. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43610222
19. Cloudflare's AI Platform: an inference layer designed for agents — https://news.ycombinator.com/item?id=47797766
20. Workflows (local `wrangler dev`): a ~200 KB `Uint8Array` step output fails with `string or blob too big: SQLITE_TOOBIG`, but the same bytes as an `ArrayBuffer` (or a 2 MB string) succeed — https://github.com/cloudflare/workers-sdk/issues/14101
21. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43608066
22. Temporary Cloudflare accounts for AI agents — https://news.ycombinator.com/item?id=48611834
23. SQLite Is All You Need — https://news.ycombinator.com/item?id=48946048
24. Durable Object alarm loop: $34k in 8 days, zero users, no platform warning — https://news.ycombinator.com/item?id=47787042
25. Fresh first-party indexed versus unindexed lookup receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1
26. Fresh first-party compound SELECT receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1
27. Fresh first-party event-ledger size receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1
28. Fresh first-party migration-ledger receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1
29. First-party 50,000-row index benchmark receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1
30. First-party D1-to-R2 revision offload receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1


---

# The Cloudflare OS: one account running an entire build

slug: cloudflare-os · https://miscsubjects.com/a/cloudflare-os · tags: cloudflare, architecture, cloudflare-os · updated 2026-07-26T03:59:19.580Z

One Cloudflare account runs this application end to end: the pages, the API, the relational database, the append-only event store, the object store, the queue, the scheduled jobs, the headless browser, the model calls and outbound mail. No second host, no virtual machine, no container.

A **binding** is a line in a Cloudflare configuration file that attaches a resource to code and names the property the code reaches it by. `env.DB` is a binding. The resource behind it has an account-scoped identifier; the running code never sees that identifier, only the name. That indirection is why a preview deployment can point at a different database without one line of code changing.

Below: the complete binding list read out of the configuration files, the unit each one bills on, the arithmetic for a stated month, the failure that turns a hobby account into a five-figure invoice, and reports from people who ran this stack and stayed or left.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Nine bindings ship with the request layer

`wrangler.toml` at the repository root declares everything the page-and-API layer can touch. The account identifier and the workers.dev subdomain are redacted; nothing else is.

| Binding | Kind | What is behind it | Why this and not something else |
| --- | --- | --- | --- |
| `DB` | D1 database `loop-content-spine` | 91 tables: articles, the capability directory, agents, leads, tasks, sessions | Relational reads with joins and indexes. KV cannot filter; R2 cannot query |
| `LEDGER` | D1 database `loop-shared-events` | 12 tables, append-only: `events`, `invocations`, `capabilities`, hash-chain checkpoints | Separate database so a write-heavy audit trail cannot compete with page reads for the same storage limit |
| `KV` | Workers KV namespace `loop_content_kv` | 19 keys: OAuth handles, prompt settings, one cached audio blob | Config distributed to every location. Read-mostly, tolerant of eventual consistency |
| `R2` | R2 bucket `miscsubjects-ledger` | 645 objects, 12.3 MB: vendored docs, disclosure receipts, agent-turn archives | Bytes with no query on them. R2 charges nothing for egress; a database charges per row scanned |
| `AI` | Workers AI | Model inference invoked from a request | Inference without an outbound API key in the request path |
| `DIRECTORY_DO` | Durable Object class `DirectoryDO` in Worker `loop-safe-directory-do` | Slug registry plus an append-only mutation-intent log | Single strongly-consistent writer. D1 cannot hold a transaction across two requests, so the serialization point has to be an object |
| `TASKS` | Queue producer for `loop-tasks` | Work handed off so the reader is not kept waiting | A request has a wall-clock budget; a queue consumer has its own |
| `STORE` | Service binding to Worker `loop-safe-storage` | Bulk blobs in a second R2 bucket plus a D1 index over them | Keeps a second bucket and a second database out of the Pages project's binding list. Worker-to-worker, no public route |
| `META_BRIDGE` | Service binding to Worker `loop-meta-bridge` | The Meta Graph API, fronted | That Worker binds the Meta token by reference from Secrets Store. Nothing copies the token |

All nine are repeated under `[[env.preview.*]]` in the same file, with `DB` and `LEDGER` pointing at `loop-content-spine-preview` and `loop-shared-events-preview`. Pages environment overrides do not inherit: a binding declared once at the top level and not repeated under `env.preview` does not exist in a preview deployment at all. Two D1 identifiers differ; the other seven bindings are byte-identical repetitions. That duplication is the only thing stopping a pull-request preview from writing to production data.

[[embed:source:s1]]
[[embed:source:s2]]
[[embed:source:s3]]
[[embed:source:s4]]

## Seven more Workers hold what a request cannot

The Pages project is one deployment. Six sibling Workers carry the bindings a request-scoped runtime cannot own — scheduled execution, long jobs, durable state and mail — plus one that exists only to serve a single path.

| Worker | Bindings it owns | What it is for |
| --- | --- | --- |
| `loop-safe-sibling` | `AI`, `DB`, `KV`, `R2`, DO classes `ExpertDO` and `AgentDO`, Workflows `DELIVER_WF` and `SELFTEST_WF`, browser `MYBROWSER`, queue producer **and** consumer for `loop-tasks`, `send_email` binding `EMAIL`, cron `*/1 * * * *` and `0 4 * * *` | Everything on a clock, everything that survives a restart, everything that drives a browser |
| `loop-safe-directory-do` | `DB`, DO class `DirectoryDO`, SQLite migration `v1` | The one strongly-consistent writer for slugs |
| `loop-safe-storage` | R2 bucket `miscsubjects-store`, D1 `loop-storage-index` | Bulk blobs with a queryable index. `workers_dev = false`, reachable only via `STORE` |
| `loop-meta-bridge` | Secrets Store refs `META_ACCESS_TOKEN`, `META_BUSINESS_ID`, `META_API_VERSION` | Meta Graph reads only. `workers_dev = false` |
| `miscsubjects-mcp` | `DB`, `KV`, DO class `MiscsubjectsMCP` | Model Context Protocol server |
| `oip-peer` | KV namespace `oip-peer-store` | A second federation node on a second registrable domain |
| `miscsubjects-robots` | Route `miscsubjects.com/robots.txt` | One path, one Worker |

`DIRECTORY_DO` and `STORE` are ordering traps: each names another Worker by `script_name` or `service`, and if that Worker has never been deployed the next Pages deploy fails on the binding, not at the call site. The target has to exist before the pointer does.

[[embed:source:s42]]

## Secrets appear as names in code and as values nowhere in the repository

Sixty-eight uppercase environment names are referenced across the Functions code, of which 43 are credentials. They are Pages environment variables and Secrets Store references, never files in the repository. Regenerate the list:

```
grep -rhoE 'env\.[A-Z][A-Z0-9_]{3,}' functions --include='*.js' | sed 's/env\.//' | sort -u
```

Names only, values nowhere: `ADMIN_SESSION_SECRET`, `AIG_TOKEN`, `AIG_RUN_TOKEN`, `AIG_SHIM_TOKEN`, `ANTHROPIC_API_KEY`, `ARCADS_API_KEY`, `BLOOIO_API_KEY`, `CF_API_TOKEN`, `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_GLOBAL_KEY`, `GEMINI_API_KEY`, `GITHUB_TOKEN`, `GROK_API_KEY`, `KIMI_API_KEY`, `MCP_TOKEN`, `META_ACCESS_TOKEN`, `MOONSHOT_API_KEY`, `OPENAI_API_KEY`, `STORE_KEY`, `STRIPE_SECRET_KEY`, `TELEGRAM_BOT_TOKEN`, `TERMINAL_KEY`, `VAULT_UNLOCK_TOKEN`, `X_API_KEY`, `X_ACCESS_TOKEN`, and eighteen more of the same shape.

The comment in `wrangler.toml` records why these are plain Pages variables and not Secrets Store entries: wrangler 4.99 rejects `[[secrets_store_secrets]]` for a Pages project. The Meta bridge, which is a Worker and not Pages, does use Secrets Store — three references, `store_id` in the config, values never copied.

[[embed:source:s5]]

## Every binding bills on a different unit, and only three of them can run away

The Workers Paid plan is $5.00 a month minimum for the account, and that subscription covers Workers, Pages Functions, KV, Hyperdrive and Durable Objects usage up to the included amounts below. Rates as published on 2026-07-26.

| Binding kind | The unit that bills | Free plan | Paid plan |
| --- | --- | --- | --- |
| Workers / Pages Functions requests | one request, cache hits included | 100,000/day | 10M/month, then $0.30/M |
| Workers CPU time | CPU milliseconds burned, not wall clock | 10 ms/invocation | 30M CPU-ms/month, then $0.02/M |
| D1 rows read | rows a query **scans**, not rows it returns | 5M/day | 25 billion/month, then $0.001/M |
| D1 rows written | rows changed, plus one per index touched | 100,000/day | 50M/month, then $1.00/M |
| D1 storage | GB summed across every database on the account | 5 GB total | 5 GB, then $0.75/GB-month |
| KV reads | one key, including reads that return null | 100,000/day | 10M/month, then $0.50/M |
| KV writes / deletes / lists | one key, or one list call | 1,000/day each | 1M/month each, then $5.00/M |
| KV storage | GB stored | 1 GB | 1 GB, then $0.50/GB-month |
| R2 storage | GB-month, standard class | 10 GB-month/month | $0.015/GB-month |
| R2 Class A operations | writes and listings | 1M/month | $4.50/M |
| R2 Class B operations | reads | 10M/month | $0.36/M |
| R2 egress | bytes to the internet | free | free |
| Durable Object requests | HTTP, RPC sessions, WebSocket messages **and alarm invocations** | 100,000/day | 1M/month, then $0.15/M |
| Durable Object duration | gigabyte-seconds the object is resident | 13,000 GB-s/day | 400,000 GB-s/month, then $12.50/M GB-s |
| Durable Object SQLite rows | same counters and rates as D1 | 5M read / 100,000 written per day | 25 billion read / 50M written per month |
| Queues | one operation; write + read + delete is three | 10,000/day | 1M/month, then $0.40/M |
| Workflows steps | one persisted step | 3,000/day | 500,000/month, then $0.80 per extra 100,000 |
| Browser | browser-hours, plus concurrent browsers averaged monthly | 10 minutes/day | 10 hours/month then $0.09/hour; 10 concurrent then $2.00/browser |
| Email sending | outbound transactional mail | not available | included with Workers Paid; sending to verified destination addresses is free on all plans |

Three of those units are unbounded by traffic and therefore the ones to watch. **Durable Object requests** count alarm invocations, so a self-scheduling object bills whether or not anyone visits. **D1 rows read** counts a scan, so one missing index multiplies the bill by the size of the table. **Queue operations** count three per message, so a retry storm triples on a metric that is already tripled.

[[embed:source:s6]]
[[embed:source:s7]]
[[embed:source:s8]]
[[embed:source:s9]]
[[embed:source:s10]]
[[embed:source:s11]]
[[embed:source:s12]]
[[embed:source:s13]]
[[embed:source:s14]]

## One index turns 2,186 rows read into 1

The same lookup, run against the live production database twice on 2026-07-26, once through the slug index and once as a scan:

```
npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT slug FROM articles WHERE slug='cloudflare-os'"
# "rows_read": 1, "duration": 0.1746

npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT slug FROM articles WHERE body LIKE '%DIRECTORY_DO%' LIMIT 5"
# "rows_read": 2186, "duration": 23.1205
```

Same table, same result column, 2,186 times the billable work and 132 times the latency. `LIMIT 5` does not help, because the limit applies to rows returned and the meter counts rows scanned. Every D1 query returns a `meta` object carrying `rows_read` and `rows_written`; that object, not the dashboard, is the cheapest place to catch a query that scans a table.

[[embed:source:s18]]
[[embed:source:s19]]
[[embed:source:s43]]

## The arithmetic for one month at one million page views

Assume one million requests a month against this application, four D1 queries per page render, all of them indexed lookups that read fewer than fifty rows each, one KV read per render for the settings blob, and the storage measured above.

```
Workers Paid subscription                                        $5.00
Requests   1,000,000  (10,000,000 included)                      $0.00
CPU time   1,000,000 x 7 ms = 7,000,000 CPU-ms
           (30,000,000 included)                                 $0.00
D1 rows read   1,000,000 x 4 queries x 50 rows = 200,000,000
           (25,000,000,000 included)                             $0.00
D1 storage 281,989,120 + 1,061,666,816 bytes = 1.34 GB
           (5 GB included)                                       $0.00
KV reads   1,000,000  (10,000,000 included)                      $0.00
R2 storage 12,261,706 bytes = 0.0123 GB  (10 GB-month included)  $0.00
Queues     assume 20,000 messages x 3 operations = 60,000
           (1,000,000 included)                                  $0.00
                                                          ------------
TOTAL                                                            $5.00
```

The included allowances are the point. At a million page views a month this application costs the subscription and nothing else. The same page views on the unindexed query above — 1,000,000 × 4 × 2,186 = 8,744,000,000 rows read — still land inside the 25 billion included, and at ten million page views become 87.44 billion rows, of which 62.44 billion bill at $0.001 per million: **$62.44**. A single missing index on a 2,186-row table is a $62 line item. On a table with a million rows it is a bill that needs its own meeting.

[[embed:source:s44]]

## $34,895 in eight days, with zero users and no alert

The failure that matters is not slow growth. It is a Durable Object scheduling its own wake-up.

A pre-launch solo founder posted the postmortem to Hacker News on 2026-04-16. His agent object called `setAlarm()` in `onStart()` without checking whether an alarm was already pending, so every wake-up scheduled another. Sixty-plus preview deployments each created independent object instances, each running its own copy of the loop. His timeline: began 3 April with zero prior Durable Object usage; peaked 4–5 April at roughly 930 billion row reads a day; found and fixed 11 April; invoice due 15 April, $34,895.

Work the peak day against the published rate. 930,000,000,000 ÷ 1,000,000 = 930,000 million-row units × $0.001 = **$930 for one day of row reads**. Eight days at that peak is $7,440. The post does not itemise the invoice, so the remaining ~$27,000 is Durable Object requests — one per alarm invocation, $0.15 per million — plus duration at $12.50 per million GB-seconds for objects that never went idle. Row reads do not explain the number. Alarms billing as requests do.

Why nothing warned him is the platform-level lesson, and the reason this sits on the index page rather than in the Durable Objects article: Workers Usage Notifications monitor CPU time. They do not monitor Durable Object row reads or writes, and there is no hard spending cap for Durable Object operations in the dashboard or in a wrangler file. Nothing was going to fire.

The fix from the same post is three lines — read `getAlarm()` first, call `setAlarm()` only if it is empty. The two structural fixes alongside it: strip Durable Object bindings out of preview environments entirely, and deploy a separate budget-monitor Worker as a kill switch, because the platform will not.

[[embed:source:s25]]

## Pick the binding by the job, not by the brand

| The job | The binding | Why, in one line | Depth |
| --- | --- | --- | --- |
| Relational data you filter, join and sort | D1 | The only binding running SQL over a schema you control | [D1](/a/cloudflare-os-d1) |
| Hot config read everywhere, written rarely | KV | Everywhere at once, eventually consistent, cheap reads and expensive writes | [KV](/a/cloudflare-os-kv) |
| Files, images, archives — bytes with no query | R2 | Free egress, $0.015 per GB-month, no per-row meter | [R2](/a/cloudflare-os-r2) |
| Serving pages and answering API calls | Pages Functions | One deployment for the page and its API, so no version skew | [Functions](/a/cloudflare-os-functions) |
| Long-lived state one caller at a time must own | Durable Objects | Single-threaded, own SQLite. The only strong-consistency primitive here | [Workers and DOs](/a/cloudflare-os-workers) |
| Work the reader must not wait for | Queues | Producer in the request, consumer with its own time budget | [Async](/a/cloudflare-os-async) |
| Multi-step jobs that must survive a restart | Workflows | Each step persisted, so a crash resumes rather than restarts | [Async](/a/cloudflare-os-async) |
| Anything on a clock | Cron triggers | 15 minutes of CPU per invocation against 30 seconds for a request | [Async](/a/cloudflare-os-async) |
| Fetching pages a plain HTTP client cannot | Browser | Real headless Chromium inside the account, billed by browser-hour | [Browser](/a/cloudflare-os-browser) |
| Outbound transactional mail | `send_email` binding | No mail vendor and no SMTP credentials in the request path | not published |
| Protecting an admin surface | Cloudflare Access | Identity in front of a route, before the Worker runs | not published |

[[embed:source:s15]]
[[embed:source:s16]]
[[embed:source:s17]]

## Six documented reasons to keep an application off this platform

**Data residency, below Enterprise.** Workers gives no choice of region or country for where code executes; the product that constrains it, Regional Services, is an Enterprise add-on. One developer moved a whole TypeScript Workers project to Django over it: *"I recently ported an entire TS project from cloudflare workers to a django python app since cloudflare workers don't support choice of region/country when deploying workers."* The nuance his complaint misses: Durable Objects **do** take a jurisdiction on every plan — `env.MY_DURABLE_OBJECT.jurisdiction("eu")`, with `eu`, `us` and `fedramp` permitted — and then run and persist only there. State can be pinned; request handling cannot. Both are true, of different layers.

**D1 latency and reliability, over a year in production.** *"Using D1 in production for over an year on multiple projects - I can confirm response times to simple queries regularly take 400ms and beyond. On top there's constant network, connection and a plethora of internal errors."* A second production user, a year later on a different thread: *"D1 reliability has been bad in our experience. We've had queries hanging on their internal network layer for several seconds, sometimes double digits over extended periods (on the order of weeks)."*

**No transaction across two requests.** *"Another fun limitation is that a transaction cannot span multiple D1 requests, so you can't select from the database, execute application logic, and then write to the database in an atomic way."* His workaround: pack a precondition into the first statement of a batch and make it throw on purpose.

**Per-tenant database sharding does not work in practice.** The documented one-database-per-customer pattern collapses on the binding model: *"It's not possible to set up per-user data in D1. Like in theory you probably could, but the DX infrastructure to make it possible is non-existent - you have to explicitly bind each database into your worker."*

**The deployment tool, if you live in a monorepo.** A developer who moved a Cloudflare stack to Azure gave tooling as the reason, not price: *"I found the experience of maintaining and deploying workers to be terrible and so monorepo-unfriendly due to wrangler forced usage for deploys (or at least I haven't found better ways)."*

**Products get superseded underneath you.** Pages, which this request layer runs on, is now something Cloudflare recommends migrating away from — described approvingly: *"This is kind of what happened (is happening) with pages right now. Workers gained pretty much all of their features and are now the recommended way to deliver static sites too."* One developer cannot follow that recommendation at all: *"I had to use Pages since Workers don't support 'Custom domains outside Cloudflare zones'. There's no way I can transfer the domain since I have subdomains tightly integrated with AWS services."*

[[embed:source:s26]]
[[embed:source:s27]]
[[embed:source:s28]]
[[embed:source:s29]]
[[embed:source:s30]]
[[embed:source:s31]]
[[embed:source:s32]]
[[embed:source:s33]]

## Four people who stayed, at four different scales

**Someone who could not code five months earlier shipped the whole stack.** *"Stack: Next.js 14 + React 18 on Cloudflare Pages, Hono 4.10 API on Cloudflare Workers (60+ route modules), 4x Cloudflare D1 databases (~180 tables total), Neon Postgres via Prisma + Hyperdrive, KV + R2 + Durable Objects + Queues."* Four databases and roughly 180 tables is not a toy schema.

**A migration off managed Postgres took a week.** *"In a week, from start to production, I migrated an AWS RDS database to Cloudflare D1 behind an OpenAPI REST interface over HTTPS using itty-router-openapi [1]. This will save me at least $250 pa."*

**Multi-million monthly actives on SQLite inside Durable Objects.** *"We serve multi million MAU on sqlite orchestrated through durable objects. It's not the most complex thing in the world but it goes further than CRUD. It costs us such a small amount of money for what it does."*

**A live multiplayer game, with the cost boundary stated out loud.** Answering a challenge that Durable Objects are too expensive for a game: *"DO alarms handle the time-based stuff (fleet arrivals, combat resolution, resource ticks) so there's no persistent connection cost. so far costs have been negligible."* The caveat is the useful half: that holds because the game is tick-based. Anything realtime wants WebSockets, and the billing changes with it.

[[embed:source:s34]]
[[embed:source:s35]]
[[embed:source:s36]]
[[embed:source:s37]]

## The person who built Workers argues against the default choice

*"It's almost always better to use Durable Objects storage, rather than D1. Even if you only want a single global database, it's better to implement that as a singleton Durable Object, than by using D1."* His reasoning: D1 already **is** a singleton Durable Object wrapping SQLite, so going direct puts the code in the same place as the data instead of one hop away, and read replication is the only thing D1 keeps that a raw object does not have.

He is as blunt in the other direction about KV, to a developer using it as a datastore: *"KV is not a distributed database and is really not intended as a database alternative at all. It's more meant for distributing bits of config globally. Cost aside, writes are way too slow for database-ish use."* That is why the KV namespace in this application holds 19 keys and not 19,000.

Against both sits a production dissent from the same D1 thread — *"I am running 2 production apps on Cloudflare workers, both using D1 for primary storage. I found the performance ok, especially after enabling Smart Placement"* — and an independent measurement from a purpose-built harness landing between the positions: *"North America performance (US and Mexico) had ~200ms+ latency per query, spiking to 500ms or higher in the test application I made using workers and D1."*

Both sides measured something real. D1's convenience and D1's tail latency are both real, and which dominates depends on whether the Worker and the database end up in the same region. Smart Placement is the one lever that decides it. If a 400 ms tail on a simple query is unacceptable, the read path does not belong on D1.

[[embed:source:s38]]
[[embed:source:s39]]
[[embed:source:s40]]

## Symptom, cause, fix

Error strings verbatim, each from a filed report or from a command run against this account.

| Symptom | Cause | Fix |
| --- | --- | --- |
| `too many terms in compound SELECT: SQLITE_ERROR [code: 7500]` | More than about five `UNION ALL` terms in one D1 statement | Split it, or use `d1.batch()` |
| `workers.api.error.script_too_large` on deploy, fine locally | The bundled Pages Functions script exceeds 1 MiB. One report is a 2.4 MB compiled WASM file; another is a blog that crossed 200 posts | Move the asset to R2 and fetch it, or split the route into its own Worker |
| `string or blob too big: SQLITE_TOOBIG` on a Workflow step | A step output above the SQLite value ceiling — reported at ~200 KB as a `Uint8Array`, while the same bytes as an `ArrayBuffer` pass | Write the payload to R2, return the key as the step output |
| Every route returns 500 under `wrangler dev --remote`, production fine | Declaring a queue producer binding breaks all routes in remote dev, including routes that never touch the queue | Develop locally, or comment the producer out while using `--remote` |
| Browser Rendering REST calls return 400, codes 7003 / 7000, token verified | Reported against the REST endpoint with correct account id and permissions | Call the `MYBROWSER` binding from inside a Worker instead of the REST API |
| A Durable Object alarm silently stops firing forever | A failed handler leaves a past timestamp in storage, so every later scheduling call sees an alarm set and skips | Compare `getAlarm()` against `Date.now()`, not against null |
| Pages Function returns 500 with an empty body in production only, no logs | The script fails before it can emit output | Wrap the handler in try/catch, return the error text, deploy again |
| Access-fronted API: `/api/health` works, every scoped call fails | The Access JWT carries an identity that is not a member of the resource requested | Map the service-token identity to a real principal before authorising |
| Forwarded mail silently never arrives | Recipient providers block Cloudflare's forwarding IP ranges: *"most times Outlook just blocks Cloudflare IP ranges and emails never get routed to my Outlook mail box"* | Send from your own authenticated domain instead of relying on forwarding |

[[embed:source:s20]]
[[embed:source:s21]]
[[embed:source:s22]]
[[embed:source:s23]]
[[embed:source:s24]]
[[embed:source:s41]]

## The volume: nine pages, seven of them live

| Page | What it settles | Status |
| --- | --- | --- |
| [D1 as the spine](/a/cloudflare-os-d1) | Why two SQL databases, one append-only | live |
| [KV as the fast lane](/a/cloudflare-os-kv) | What belongs in KV when SQL is already present | live |
| [R2 as the object store](/a/cloudflare-os-r2) | What free egress is worth, and what R2 does not do that S3 does | live |
| [Pages Functions as the request layer](/a/cloudflare-os-functions) | One deployment for every page and route, and its size ceiling | live |
| [Workers and Durable Objects](/a/cloudflare-os-workers) | Jobs that cannot live in a request, and the objects holding state between them | live |
| [Queues, workflows and cron](/a/cloudflare-os-async) | Three different answers to "later", and which one each job needs | live |
| [Browser Rendering as the eyes](/a/cloudflare-os-browser) | Fetching what a plain client cannot, and what a browser-hour costs | live |
| Email | The `send_email` binding, routing rules, and the authentication result that made mail land | not written; `/a/cloudflare-os-email` redirects here |
| Access and secret posture | Which surfaces are public, which need the owner key, the one-token model | not written; `/a/cloudflare-os-access` redirects here |

The capability layer above every binding here — one table of rows, each naming a function, an endpoint, a model or a flow — is documented in [891 tools, zero tool schemas](/a/tooling-as-data), [What a directory row is](/a/directory-row-contract) and [The four-step loop](/a/dispatch-four-step-loop).

## How the counts on this page were taken

Four commands, run from the repository root on 2026-07-26. The account identifier wrangler prints in error output is redacted.

```
# 387 JavaScript files under functions/, 214 of them exporting a request handler,
# 271 exported handler functions in total
find functions -name '*.js' | wc -l
grep -rlE 'export (async )?(function|const) onRequest' functions --include='*.js' | wc -l
grep -rhoE 'export (async )?(function|const) onRequest[A-Za-z]*' functions --include='*.js' | wc -l

# 91 tables in the content spine, 12 in the event store
npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT name FROM sqlite_master WHERE type='table'"

# row counts; the meta object in the response carries rows_read and size_after
npx wrangler d1 execute loop-shared-events --remote --json --command \
 "SELECT 'events' t, COUNT(*) n FROM events UNION ALL SELECT 'invocations', COUNT(*) FROM invocations UNION ALL SELECT 'capabilities', COUNT(*) FROM capabilities UNION ALL SELECT 'anchors', COUNT(*) FROM anchors"
# events 400,756 · invocations 159,761 · capabilities 1,026 · anchors 6
# "rows_read": 561549 · "size_after": 1061666816

# three R2 buckets, six KV namespaces on the account
npx wrangler r2 bucket list
npx wrangler kv namespace list
```

The 387 figure matches the count recorded for this project earlier and is unchanged today. Those four `COUNT(*)` terms read 561,549 rows between them — $0.00056 of billable work, and the exact shape of the query that stops being free when the tables are a thousand times larger. `COUNT(*)` scans.


## Sources

1. Cloudflare Workers bindings — https://developers.cloudflare.com/workers/runtime-apis/bindings/
2. Cloudflare Pages Functions bindings — https://developers.cloudflare.com/pages/functions/bindings/
3. Cloudflare service bindings — https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/
4. Wrangler configuration reference — https://developers.cloudflare.com/workers/wrangler/configuration/
5. Cloudflare Workers secrets — https://developers.cloudflare.com/workers/configuration/secrets/
6. Cloudflare Workers pricing — https://developers.cloudflare.com/workers/platform/pricing/
7. Cloudflare D1 pricing — https://developers.cloudflare.com/d1/platform/pricing/
8. Cloudflare Workers KV pricing — https://developers.cloudflare.com/kv/platform/pricing/
9. Cloudflare R2 pricing — https://developers.cloudflare.com/r2/pricing/
10. Cloudflare Durable Objects pricing — https://developers.cloudflare.com/durable-objects/platform/pricing/
11. Cloudflare Queues pricing — https://developers.cloudflare.com/queues/platform/pricing/
12. Cloudflare Workflows pricing — https://developers.cloudflare.com/workflows/reference/pricing/
13. Cloudflare Browser Rendering pricing — https://developers.cloudflare.com/browser-rendering/pricing/
14. Send emails from Workers — https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/
15. Durable Object data location — https://developers.cloudflare.com/durable-objects/reference/data-location/
16. Cloudflare Regional Services — https://developers.cloudflare.com/data-localization/regional-services/
17. Migrate from Pages to Workers — https://developers.cloudflare.com/pages/migrate-to-workers/
18. D1 query API reference — https://developers.cloudflare.com/api/resources/d1/subresources/database/methods/query/
19. KV versus D1 benchmark harness — https://github.com/bruceharrison1984/kv-d1-benchmark
20. Workflows (local `wrangler dev`): a ~200 KB `Uint8Array` step output fails with `string or blob too big: SQLITE_TOOBIG`, but the same bytes as an `ArrayBuffer` (or a 2 MB string) succeed — https://github.com/cloudflare/workers-sdk/issues/14101
21. Deploying on Cloudflare Pages, script_too_large? — https://github.com/nuxt-modules/og-image/issues/193
22. Queue producer binding causes 500 errors on all routes when using `wrangler dev --remote` — https://github.com/cloudflare/workers-sdk/issues/9642
23. Cloudflare Browser Rendering API (Code 7003/7000) Failure in Worker — https://github.com/cloudflare/workers-sdk/issues/10864
24. [BUG] Durable Objects alarm not firing due to stale past alarms remaining in storage — https://github.com/opennextjs/opennextjs-cloudflare/issues/929
25. Durable Object alarm loop: $34k in 8 days, zero users, no platform warning — https://news.ycombinator.com/item?id=47787042
26. Cloudflare recommends migrating from Pages to Workers — https://news.ycombinator.com/item?id=44855519
27. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43607561
28. Cloudflare's AI Platform: an inference layer designed for agents — https://news.ycombinator.com/item?id=47797766
29. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43614249
30. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43610222
31. Lowstorage: JSON-based database for Cloudflare Workers and R2 buckets — https://news.ycombinator.com/item?id=38509302
32. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43646198
33. Cloudflare recommends migrating from Pages to Workers — https://news.ycombinator.com/item?id=44854848
34. 5 months ago I'd never coded anything. I now have full-stack analytics platform — https://news.ycombinator.com/item?id=47146087
35. Without saying "it's scalable", convince me that Serverless is worth it — https://news.ycombinator.com/item?id=38964629
36. SQLite Is All You Need — https://news.ycombinator.com/item?id=48946048
37. Show HN: I rebuilt a 2000s browser strategy game on Cloudflare's edge — https://news.ycombinator.com/item?id=47785298
38. Temporary Cloudflare accounts for AI agents — https://news.ycombinator.com/item?id=48611834
39. OAuth for all — https://news.ycombinator.com/item?id=48672342
40. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43608066
41. Cloudflare Email Service: private beta — https://news.ycombinator.com/item?id=45373715
42. Configuration inventory reproduced from the production repository — https://miscsubjects.com/a/cloudflare-os
43. Indexed and scanning D1 query comparison — https://miscsubjects.com/a/cloudflare-os
44. Production resource counts — https://miscsubjects.com/a/cloudflare-os


---

# Two id families, a 23x price gap: choosing a coding model on Cloudflare

slug: workers-ai-coding-models · https://miscsubjects.com/a/workers-ai-coding-models · tags: tooling, cloudflare, workers-ai, ai-gateway, coding-agents, model-pricing · updated 2026-07-26T03:31:50.293Z

Two model ids look almost the same and are not the same product.

`@cf/moonshotai/kimi-k2.7-code` runs on Cloudflare's GPUs: published per-token price, listed in the account's model catalogue over the API, billed as Workers AI against the same Neuron allowance as an image classifier.

`moonshotai/kimi-k3` runs on Moonshot's GPUs and Cloudflare resells it: no per-token price published anywhere in the documentation, absent from the account catalogue, billed through Unified Billing against prepaid credits.

The prefix is the whole difference. Get it wrong and the request either costs twenty times what was budgeted or returns a 402.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## The prefix decides the bill, the discovery path and the request shape

| | Workers AI model | Catalogue (partner) model |
| --- | --- | --- |
| Id shape | `@cf/vendor/model` | `vendor/model` |
| Examples | `@cf/moonshotai/kimi-k2.7-code`, `@cf/zai-org/glm-5.2`, `@cf/zai-org/glm-4.7-flash` | `moonshotai/kimi-k3`, `xai/grok-4.5`, `minimax/m3` |
| Who runs the GPU | Cloudflare | The model vendor |
| Billing | Workers AI, Neurons, $0.011 per 1,000 Neurons, 10,000 Neurons free per day | Unified Billing, prepaid credits, 5% fee on credit purchase, provider rates passed through |
| Per-token price published? | Yes, on the pricing page and in the models API | No — the model page links to the dashboard |
| Listed by `GET /ai/models/search`? | Yes | No |
| Needs an authenticated gateway? | No | Yes — an unauthenticated gateway answers 402 |
| Free daily allowance applies? | Yes | No |
| Anthropic Messages endpoint | Refused by name | Sometimes accepted, shape not guaranteed |

Cloudflare states the split in one sentence: "Workers AI models (models prefixed with `@cf/`) routed through AI Gateway are not charged via Unified Billing." The credit mechanics, the 5% purchase fee and the authentication requirement are covered in [Cloudflare Unified Billing](/a/cloudflare-unified-billing).

## Every coding-relevant model Cloudflare hosts, priced from the account catalogue

The list below is the account's own catalogue, not the documentation. Fetch it:

```bash
ACCOUNT_ID=<ACCOUNT_ID>            # wrangler whoami
CF_API_TOKEN=<TOKEN>               # Workers AI: Read

curl -s -H "Authorization: Bearer $CF_API_TOKEN" \
  "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/ai/models/search?per_page=500" \
  | jq -r '.result[] | select(.task.name=="Text Generation") |
      [.name,
       (.properties[]|select(.property_id=="context_window").value),
       ((.properties[]|select(.property_id=="function_calling").value) // "no")]
      | @tsv'
```

Read on 2026-07-26: 61 models in the catalogue, 26 of them Text Generation, and **13 in the whole catalogue advertise function calling**. Prices below are the `price` property returned by that same call, in US dollars per million tokens.

| Model id | Context | Tools | Vision | Input | Cached input | Output | What it is for |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `@cf/moonshotai/kimi-k2.7-code` | 262,144 | yes | yes | $0.95 | $0.19 | $4.00 | Main agent thread. The only hosted model with a large window and image input together. |
| `@cf/moonshotai/kimi-k2.6` | 262,144 | yes | yes | $0.95 | $0.16 | $4.00 | Previous Kimi. Same price, cheaper cache, no reason to pick it for new work. |
| `@cf/zai-org/glm-5.2` | 262,144 | yes | no | $1.40 | $0.26 | $4.40 | Second opinion from a different family. Most expensive hosted option. |
| `@cf/zai-org/glm-4.7-flash` | 131,072 | yes | no | $0.0605 | none | $0.40 | Background turns: titles, summaries, classification. 23x cheaper input than GLM-5.2. |
| `@cf/nvidia/nemotron-3-120b-a12b` | 256,000 | yes | no | $0.50 | none | $1.50 | Long context where output volume, not reasoning depth, dominates the bill. |
| `@cf/openai/gpt-oss-120b` | 128,000 | yes | no | $0.35 | none | $0.75 | Cheap tool calls, open licence. Window too small for a loaded agent. |
| `@cf/openai/gpt-oss-20b` | 128,000 | yes | no | $0.20 | none | $0.30 | Cheapest tool-caller with a six-figure window. |
| `@cf/google/gemma-4-26b-a4b-it` | 256,000 | yes | no | $0.10 | none | $0.30 | Large window, low price, no coding track record. |
| `@cf/qwen/qwen3-30b-a3b-fp8` | 32,768 | yes | no | $0.0509 | none | $0.335 | Cheapest tool-caller here. The window is the problem. |
| `@cf/qwen/qwen2.5-coder-32b-instruct` | 32,768 | **no** | no | $0.66 | none | $1.00 | Completion, not agency. Cannot call tools. |
| `@cf/deepseek-ai/deepseek-r1-distill-qwen-32b` | 80,000 | **no** | no | $0.497 | none | $4.881 | Cannot call tools, and the highest output price here. |

Two rows are traps. `qwen2.5-coder-32b-instruct` carries "coder" in the name and cannot call tools, so no agent can drive it. The DeepSeek distill is the same, at nine times the output price of GPT-OSS-120B. Cloudflare's [function calling](https://developers.cloudflare.com/workers-ai/features/function-calling/) page describes the capability; the catalogue is the only place that says which models have it.

## The catalogue models publish no price — the only way to learn it is to run one and read the log

`moonshotai/kimi-k3`, `xai/grok-4.5` and `minimax/m3` each have a documentation page. Each page has a Pricing row. Each Pricing row says the same thing: "View pricing in the Cloudflare dashboard".

The figures below are measured, not published: one identical request per model through the account's AI Gateway on 2026-07-26, cost read back from the gateway log rows.

| Model id | Context (docs) | Request formats (docs) | Published rate | Measured cost, this turn | Tokens in / out | Blended $/M |
| --- | --- | --- | --- | --- | --- | --- |
| `moonshotai/kimi-k3` | 1,048,576 | Chat Completions | none | $0.002283 | 126 / 127 | $9.02 |
| `xai/grok-4.5` | 500,000 | Chat Completions | none | $0.0010764 | 248 / 37 | $3.78 |
| `minimax/m3` | 1,000,000 | Chat Completions, Anthropic Messages | none | $0.00011934 | 217 / 68 | $0.42 |

A single observation cannot separate an input rate from an output rate — two unknowns, one equation. It does establish the order of magnitude: Kimi K3 costs roughly twenty times per token what MiniMax M3 costs for the same answer. Separating the two rates needs a second request with a deliberately different input-to-output ratio, then solving the pair.

## Three Cloudflare surfaces disagree about what GLM-4.7 Flash costs

Workers AI bills in Neurons and projects them into dollars. The projection is where the surfaces drift apart.

| Surface | GLM-4.7 Flash, per M input tokens |
| --- | --- |
| Pricing page, "Price in Tokens" column | $0.060 |
| Pricing page, "Price in Neurons" column | 5,500 neurons, which at $0.011 per 1,000 Neurons is $0.0605 |
| Models API `price` property | $0.0605 |
| AI Gateway log `cost` field | behaves as $0.060 |

The check that settles it: a direct Workers AI call returns a `neurons` figure in its usage block. An 18-input, 24-output turn returned `"neurons": 0.9726`, and 18 x 5,500/1e6 + 24 x 36,400/1e6 = 0.9726 exactly. Neurons are the real unit; the dollar columns are rounded projections. Meanwhile the gateway's `cost` field for a 46-input, 587-output turn came back as $0.00023756, which is 46 x $0.060 + 587 x $0.40 exactly, not 46 x $0.0605 + 587 x $0.40 = $0.000237583.

The gap is $0.000000023 on that turn. It matters because it means the log's dollar column is not authoritative to the last digit, which is worth knowing before building a chargeback report on it.

## The background slot is where a coding agent's money actually goes

A coding agent runs two model slots. The main slot answers the user. A second, smaller slot runs constantly and invisibly: naming the session, summarising the conversation when the window fills, classifying whether a command is safe. In Claude Code that slot is the environment variable `ANTHROPIC_DEFAULT_HAIKU_MODEL`. It fires whether or not anyone is watching, which is what makes a 23x input price difference compound.

Two turn shapes, priced through each model's published rate:

| Turn shape | GLM-4.7 Flash | Kimi K2.7 Code | GLM-5.2 |
| --- | --- | --- | --- |
| Measured short turn, 46 in / 587 out | $0.000238 | $0.002392 | $0.002647 |
| Conversation summary, 20,000 in / 500 out | $0.00141 | $0.02100 | $0.03020 |
| 2,000 summary turns in a month | **$2.82** | **$42.00** | **$60.40** |

The middle row written out: 20,000 x $0.0605/1,000,000 = $0.00121 input plus 500 x $0.40/1,000,000 = $0.0002 output, giving $0.00141. For GLM-5.2: 20,000 x $1.40/1,000,000 = $0.028 plus 500 x $4.40/1,000,000 = $0.0022, giving $0.0302. The 2,000-turn count is a stated assumption; the per-turn figures are not.

$57.58 a month, on turns no one reads. Point the background slot at `@cf/zai-org/glm-4.7-flash` and the main slot at whatever is worth paying for.

## How to call one

Both surfaces take a Cloudflare API token with `Workers AI: Read` and `Workers AI: Run`. Setting up the gateway itself: [How to create a Cloudflare AI Gateway](/a/cloudflare-ai-gateway-setup).

**Workers AI, OpenAI-compatible.** This is the shape almost every client expects.

```bash
curl -s -X POST \
  "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai/v1/chat/completions" \
  -H "Authorization: Bearer <TOKEN>" \
  -H "content-type: application/json" \
  -d '{"model":"@cf/zai-org/glm-4.7-flash","max_tokens":24,
       "messages":[{"role":"user","content":"What is 2+2? Answer with the number only."}]}'
```

The real response, 2026-07-26, trimmed to the fields that matter:

```json
{"model":"@cf/zai-org/glm-4.7-flash",
 "choices":[{"message":{"role":"assistant","content":null,
   "reasoning_content":"1.  **Analyze the user's request:** The user is asking for the sum of 2 and 2"},
   "finish_reason":"length"}],
 "usage":{"prompt_tokens":18,"completion_tokens":24,"total_tokens":42,"neurons":0.9726}}
```

`content` is `null`. That is not an error condition — see below.

To log, cache and rate-limit the call, add one header to the same request: `-H "cf-aig-gateway-id: <GATEWAY_ID>"`. Catalogue models use the identical path and body with the unprefixed id, for example `"model":"minimax/m3"`.

## The Anthropic endpoint refuses every `@cf/` model by name

Cloudflare exposes `POST /ai/v1/messages`. Sending a Workers AI model to it returns, verbatim:

```json
{"type":"error","error":{"type":"invalid_request_error",
 "message":"AiError: Anthropic Messages API is not supported for model \"@cf/moonshotai/kimi-k2.7-code\""}}
```

Catalogue models are accepted there, and the shape is not what the endpoint name promises. The documentation for `minimax/m3` lists its request formats as "Chat Completions, Anthropic Messages". Sent to `/ai/v1/messages` on 2026-07-26 it answered:

```json
{"id":"06b4c281ce76f4c5405e21e09af57dfa","model":"MiniMax-M3","object":"chat.completion",
 "choices":[{"message":{"role":"assistant","content":"",
   "reasoning_content":"The user simply said \"say OK\"..."},"finish_reason":"length"}],
 "usage":{"total_tokens":210,"prompt_tokens":178,"completion_tokens":32}}
```

`"object":"chat.completion"` and a `choices` array: an OpenAI body from an Anthropic-named endpoint. A client that parses `content` as an array of blocks throws on it. So a tool speaking only `POST /v1/messages` cannot reach any Workers AI model without a translator, and cannot trust the catalogue models' envelope either. That translator, with its source, is in [Claude Code on Kimi, GLM or Grok through your own Cloudflare account](/a/claude-code-on-cloudflare-ai-gateway).

## What breaks

**Reasoning models spend the output budget thinking and hand back nothing.** Kimi and GLM both emit a reasoning trace before the answer, charged as output and counted against `max_tokens`. The call above, capped at 24, returned `finish_reason: "length"`, `content: null`, and a bill for 24 output tokens. The same prompt at `max_tokens: 1024` returned `"4"` after 78 output tokens. A client reading only `content` sees an empty turn; one falling back to `reasoning_content` presents the scratchpad as the answer. Give the cap headroom.

**Constrained decoding turns that into a total failure.** Cloudflare's [JSON Mode](https://developers.cloudflare.com/workers-ai/features/json-mode/) accepts a `response_format` carrying a JSON Schema and forces the output to match it — which forbids the `<think>` preamble a reasoning model emits first. On the Workers AI binding, debuggingfuture root-caused an every-call failure to exactly this: "glm-4.7-flash answers correctly in the Cloudflare playground but failed **every** pr-review with `StructuredOutputInvalid: empty`." GLM-5.2 failed identically, which proved the decode path was at fault rather than the model, and led him to retract his own earlier "GLM is out" verdict.

Rerun on the REST path on 2026-07-26, the failure is a budget failure and it is escapable. `@cf/zai-org/glm-4.7-flash` with a two-field `json_schema` at `max_tokens: 256` returned `content: null` and 256 output tokens of reasoning. The identical request at `max_tokens: 2048` returned `{"score": 6, "why": "This code is syntactically correct and does exactly what it is supposed to do. However, it lacks context, documentation, and best practices."}` after 760 output tokens, 2,634 characters of which were discarded reasoning. Binding path and REST path do not behave the same way; the rule that covers both is to budget three to ten times the tokens the answer needs, or not to send guided JSON to a reasoning model at all.

**Corrupted output at real prompt sizes, on real traffic only.** Tenstorrent's serving stack produced garbage from Kimi K2.7 Code under an eight-thousand-token structured coding prompt: "People chatting on the console were seeing corrupted outputs. We did not observe something like this during the weekend nor with release workflow with limited samples." Short smoke tests do not test this model.

**Mid-stream failure on long agentic runs.** Faith-2002, on opencode 1.18.5, hitting Kimi K2.7 Code through a third-party host: `{"type":"error","sequence_number":1584,"code":"InternalServiceError","message":"The service encountered an unexpected internal error.","param":""}` — consistently, on complex tasks, 1,584 stream events in. Cheap tokens and reliable long runs are not the same purchase.

**Vision works or not depending on which client sends the image.** vilicvane found Kimi K2.7 Code reading images fine through one VS Code integration and rejecting them through another: "The original VSCode built-in Ollama seem to work vision of with Kimi K2.7 Code. However, when use with models provided by this extension, Kimi complains corrupted images." The catalogue's `vision: true` property is accurate; the payload shape the client builds decides whether it works.

**Announced availability is not granted entitlement.** aregtech cited GitHub's changelog announcing Kimi K2.7 for Copilot Pro, then screenshotted the CLI listing it: "The GitHub policy says that Kimi Code 2.7 (model `kimi-k2.7-code`) is available for Pro subscription. - In fact, it is listed in the `Blocked / Disabled` list." Check the catalogue call, not the announcement.

**OpenAI-compatible means Chat Completions, not everything OpenAI ships.** mrnoname set out to spend a Cloudflare Startups credit on Workers AI through Codex CLI: "Workers AI has an OpenAI-compatible API so I expected it to just work with Codex. Nope. The Responses API surface doesn't map". He wrote a proxy.

## Quality, honestly: one benchmark, one retraction, one zero

The most useful published comparison of Kimi K2.7 Code against Qwen coding models is useful mainly because its author threw his first version away. Amit Arora had published a five-model, six-task results matrix, then removed it: "The README's results section published a 5x6 matrix (Opus, Kimi, Devstral, MiniMax, Qwen Coder Next, Qwen 3.6 35B) whose per-model numbers are **not reproducible from artifacts in this repo** -- no `eval.json` files exist on disk for those models, and the figures differ materially from what the current judge produces. Publish only what we have actually measured."

What replaced it, scored by one judge (`codex exec`, `gpt-5.6-sol`, high effort), every model self-hosted on vLLM:

| Task | Kimi-K2.7-Code | Qwen3.6-35B | Qwen3-Coder-30B |
| --- | --: | --: | --: |
| remove-faiss | **75.25** | 59.25 | 49.0 |
| remove-efs | **71.25** | 63.0 | 45.0 |
| ssrf | **72.75** | 55.75 | 0.0 |
| migrate-secrets | **75.5** | 54.5 | 43.5 |
| keycloak-rds-iam | 0.0 | **48.75** | 33.25 |
| Mean of 5 | **58.95** | 56.25 | 34.15 |

The zero is the honest part. Kimi scored 0.0 on `keycloak-rds-iam`, a task Qwen3.6-35B leads, classified as a real failure rather than judging noise: it "hit the 60-turn cap with 2/4 artifacts". Qwen3-Coder-30B's zero on `ssrf` is the mirror image — it "spent every turn implementing instead of designing".

Two readings follow and they pull opposite ways. On the four tasks Kimi completed it is clearly ahead, a 73.69 mean. Across all five the margin is 58.95 to 56.25, which one blown task erases. A frontier-scale model that occasionally burns its whole turn budget and delivers half the artifacts is not strictly better than a small one that finishes.

The hardware asymmetry is stated too: "**Hardware:** Kimi-K2.7-Code (1.06T-param MoE) ran on **8x H200** (`p5en.48xlarge`); the three Qwen models (3B-active MoE) on a single **`g6e.12xlarge`** (4x L40S). All via vLLM." That is a comparison of weights, not of Cloudflare's serving of them.

On the other family the evidence is one operator changing his mind. December 2025, andai on GLM behind a coding CLI: "I had been using GLM in Claude code with Claude code router, because while you can just change the API endpoint, the web search function doesn't work, and neither does image recognition." He went back to first-party. June 2026, same person: "But it just works with Claude Code? They have a guide on their website." Between them prmph filed the other side: "For some reason I can't even get Claude Code (Running GLM 4.6) to do the simplest of tasks today without feeling like I want to tear my hair out, whereas it used to be pretty good before." Three reports, two verdicts, one direction of travel — and all three were true when written.

**The verdict.** Kimi K2.7 Code on the main thread: the only Cloudflare-hosted model with a 262,144-token window, tool calling and image input at once, leader in the only reproducible published comparison, 32% cheaper on input than GLM-5.2. GLM-4.7 Flash on the background slot. GLM-5.2 for the second opinion, not the first draft.

**What would change it.** A reproducible benchmark where Kimi's zero repeats on a second long-horizon task; a published `moonshotai/kimi-k3` rate under $2/M blended, which would make a 1M-token window affordable for the main thread; or a cached-input rate on GLM-4.7 Flash, which would make it viable for turns that repeat a large prefix.

## First-party measurement: the same coding prompt through six models

**Method.** One `POST /v1/messages` per model against the account's own Anthropic-shaped gateway route, `max_tokens: 1024`, no tools, no system prompt, single user message, sequential, one attempt each. Latency measured client-side around the `fetch`. Token counts from the response `usage` block. Cost read afterwards from the AI Gateway log rows for the same six requests, matched by timestamp and by the `model_asked` value in the request metadata. Run at 04:35:23–04:35:44 UTC on 2026-07-26.

The prompt, in full:

```
Write a Python function chunk(xs, n) that splits list xs into consecutive chunks
of length n, with a shorter final chunk if the list does not divide evenly.
Return only the code, no explanation.
```

| Requested alias | Resolved id | Latency | In | Out | Cached in | Gateway cost |
| --- | --- | --: | --: | --: | --: | --: |
| `claude-kimi-k2.7-code` | `@cf/moonshotai/kimi-k2.7-code` | 2,745 ms | 49 | 185 | 0 | $0.00078655 |
| `claude-glm-5.2` | `@cf/zai-org/glm-5.2` | 2,454 ms | 53 | 135 | 0 | $0.0006682 |
| `claude-glm-flash` | `@cf/zai-org/glm-4.7-flash` | 7,571 ms | 46 | 587 | 0 | $0.00023756 |
| `claude-kimi-k3` | `moonshotai/kimi-k3` | 6,663 ms | 126 | 127 | 0 | $0.002283 |
| `claude-grok-4.5` | `xai/grok-4.5` | 2,271 ms | 248 | 37 | 128 | $0.0010764 |
| `claude-minimax-m3` | `minimax/m3` | 1,809 ms | 217 | 68 | 114 | $0.00011934 |

**Every one of the six returned the same function.** Four wrote `xs[i:i+n]` and two wrote `xs[i:i + n]`:

```python
def chunk(xs, n):
    return [xs[i:i+n] for i in range(0, len(xs), n)]
```

**What it shows.** On a task with one obvious idiomatic answer, model choice changes nothing about the answer and a great deal about the cost. MiniMax M3 at $0.00011934 was 19 times cheaper than Kimi K3 at $0.002283 for a byte-identical result. The slowest turn came from the cheapest hosted model, GLM-4.7 Flash at 7,571 ms, because it spent 587 output tokens reasoning about a two-line function — 4.5 times the output of any other model here.

**What it does not show.** Nothing about multi-turn agentic work, tool calling, long contexts, or instruction adherence under pressure. One sample per model, one prompt, no repeats, so the latency figures carry no error bars and include whatever queueing each backend had that second. It is a floor check.

**Reconciliation.** The three Workers AI rows multiply out exactly against the published rates: 49 x $0.95 + 185 x $4.00 over a million is $0.00078655, the logged figure to the last digit; 53 x $1.40 + 135 x $4.40 is $0.0006682, likewise. A previously recorded row on this same account does *not* reconcile: a 149,187-input-token Kimi K2.7 Code turn billed $0.02852109, implying $0.191 per million — the cached rate, on a turn reporting 64 cached tokens. Published as unreconciled. The arithmetic holds at three-digit token counts and fails at six-digit ones, and the difference has not been explained.

## Choosing table

| Task | Pick | Why |
| --- | --- | --- |
| Main agent thread, tools, large repo | `@cf/moonshotai/kimi-k2.7-code` | 262,144 window, tools, vision, $0.19/M cached input, leader in the one reproducible comparison. |
| Session titles, summaries, safety classification | `@cf/zai-org/glm-4.7-flash` | $0.0605/M input against $1.40 for GLM-5.2. Same job, 23x less. |
| Second opinion on a design, different family | `@cf/zai-org/glm-5.2` | Independent weights. 47% more input, 10% more output. |
| Reading a screenshot or a diagram | `@cf/moonshotai/kimi-k2.7-code` | The only `@cf/` coding model with `vision: true`. |
| Context over 262,144 tokens | `moonshotai/kimi-k3` or `minimax/m3` | 1,048,576 and 1,000,000 tokens, both catalogue-billed and unpriced in the docs. |
| Cheapest possible tool call, small window fine | `@cf/openai/gpt-oss-20b` | $0.20/M in, $0.30/M out, 128,000 window, tools yes. |
| Bulk structured extraction with a JSON schema | `@cf/openai/gpt-oss-120b` | Guided JSON on a reasoning model burns the output budget. Budget generously. |
| Plain code completion, no agency | `@cf/qwen/qwen2.5-coder-32b-instruct` | Cheaper per output token than Kimi. Cannot drive a loop. |

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| `"content": null`, `finish_reason: "length"`, tokens still billed | Reasoning trace consumed the whole `max_tokens` budget | Raise `max_tokens`. 1,024 is the floor for a one-line GLM-4.7 Flash answer. |
| `StructuredOutputInvalid: empty` on every guided-JSON call | `response_format` schema forbids the `<think>` preamble the model emits first | Drop `response_format` and parse JSON yourself, or give 3–10x the tokens the object needs. |
| `AiError: Anthropic Messages API is not supported for model "@cf/..."` | Workers AI models are excluded from `/ai/v1/messages` | Use `/ai/v1/chat/completions`, or put a translator in front. |
| Response has `"object":"chat.completion"` from `/ai/v1/messages` | A catalogue model returned its native OpenAI body through the Anthropic-named path | Detect the envelope shape at runtime; do not trust the endpoint name. |
| HTTP 402, "Gateway authentication is required to use unified billing" | A catalogue model routed through an unauthenticated gateway | Enable authentication on the gateway and send `cf-aig-authorization`. See [Cloudflare Unified Billing](/a/cloudflare-unified-billing). |
| `{"code":"InternalServiceError"}` mid-stream on a long run | Hosted-path instability on extended agentic sessions | Retry with backoff, resume from the last completed tool result. The stream is not atomic. |
| Garbled tokens in the answer at large prompt sizes | Serving-stack corruption that short samples do not surface | Test at the prompt size actually used. |
| "corrupted images" from a vision-capable model | Client built the image part in a shape the backend does not accept | Send OpenAI `image_url` parts on `/ai/v1/chat/completions`. Verify with a tiny known PNG. |
| Model is announced but the CLI lists it blocked | Announcement and entitlement are separate systems | Call `/ai/models/search` and treat its output as the truth. |
| The id 404s or silently resolves to another model | The public model name is not the Cloudflare id | Copy the id from the catalogue call. `@cf/moonshotai/kimi-k2.5` exists but is Deprecated. |
| Codex CLI or any Responses-API client cannot connect | Workers AI implements Chat Completions, not the Responses API | Use a Chat Completions client, or a translating proxy. |
| Cost report does not tie out to the published rate | The gateway `cost` field rounds, and large-context rows have not reconciled | Reconcile on token counts from `usage`, not the dollar column. |

## Rerun any of it

The alias-to-id mapping used in the six-model run is the `CATALOGUE` table in [the gateway translator source](https://github.com/redacted/claude-code-cloudflare-gateway). Every price and capability above comes from one call; every cost from another.

```bash
# The catalogue: prices, context windows, function_calling, vision
curl -s -H "Authorization: Bearer <TOKEN>" \
 "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai/models/search?per_page=500" | jq .

# What a turn actually cost, from the gateway log (needs AI Gateway: Read, not Workers AI)
curl -s -H "Authorization: Bearer <TOKEN>" \
 "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai-gateway/gateways/<GATEWAY_ID>/logs?per_page=10&order_by=created_at&order_by_direction=desc" \
 | jq -r '.result[] | [.model, .tokens_in, .tokens_out, .cost] | @tsv'
```

## Sources

1. Workers AI pricing — https://developers.cloudflare.com/workers-ai/platform/pricing/
2. Unified Billing — https://developers.cloudflare.com/ai-gateway/features/unified-billing/
3. Cloudflare AI model catalog — https://developers.cloudflare.com/ai/models/
4. Kimi K3 (Moonshot AI) model page — https://developers.cloudflare.com/ai/models/moonshotai/kimi-k3/
5. MiniMax M3 model page — https://developers.cloudflare.com/ai/models/minimax/m3/
6. Grok 4.5 (xAI) model page — https://developers.cloudflare.com/ai/models/xai/grok-4.5/
7. JSON Mode — https://developers.cloudflare.com/workers-ai/features/json-mode/
8. Function calling — https://developers.cloudflare.com/workers-ai/features/function-calling/
9. Cloudflare API reference: AI models list — https://developers.cloudflare.com/api/resources/ai/subresources/models/methods/list/
10. AI Gateway REST API — https://developers.cloudflare.com/ai-gateway/usage/rest-api/
11. OpenAI compatible API endpoints — https://developers.cloudflare.com/workers-ai/configuration/open-ai-compatibility/
12. claude-code-cloudflare-gateway — https://github.com/redacted/claude-code-cloudflare-gateway
13. README: publish self-hosted results (Kimi-K2.7-Code + 3 Qwen models) — https://github.com/aarora79/agentic-coding-harness-benchmarks/pull/10
14. fix(review-agent): stop sending guided-JSON by default — it breaks GLM on the Workers AI binding — https://github.com/OpenHackersClub/flare-dispatch/pull/213
15. [Kimi-K2.7-Code] Corrupted Outputs — https://github.com/tenstorrent/tt-inference-server/issues/4441
16. Internal Service Error in kimi-k2.7-code — https://github.com/anomalyco/opencode/issues/38813
17. Having some trouble with vision support for Kimi K2.7 Code — https://github.com/ollama/ollama-vscode/issues/9
18. Kimi K2.7 Code is not available in Pro subscription — https://github.com/github/copilot-cli/issues/4029
19. Show HN: Codex Workers AI Proxy – Use Cloudflare Workers AI models in Codex CLI — https://news.ycombinator.com/item?id=47739925
20. Comment on "GLM-4.7: Advancing the Coding Capability" — GLM in Claude Code — https://news.ycombinator.com/item?id=46366013
21. Comment — z.ai GLM behind Claude Code via a bashrc alias — https://news.ycombinator.com/item?id=48568587
22. Comment — Claude Code running GLM 4.6 can't do simple tasks — https://news.ycombinator.com/item?id=46082971
23. Account model catalogue read on 2026-07-26 — https://miscsubjects.com/api/articles/workers-ai-coding-models
24. Six models, one coding prompt, measured latency, tokens and cost — https://miscsubjects.com/api/articles/workers-ai-coding-models
25. Reasoning budget, guided JSON and the Anthropic-endpoint refusal — https://miscsubjects.com/api/articles/workers-ai-coding-models
26. Neuron reconciliation and the row that does not reconcile — https://miscsubjects.com/api/articles/workers-ai-coding-models


---

# Cloudflare Unified Billing: the 5% is on the credits, and the 402 is one gateway toggle

slug: cloudflare-unified-billing · https://miscsubjects.com/a/cloudflare-unified-billing · tags: tooling, cloudflare, billing, ai-gateway, unified-billing, llm-routing · updated 2026-07-26T03:31:40.158Z

Cloudflare Unified Billing is a way of paying for model inference in which Cloudflare, not you, holds the credentials for OpenAI, Anthropic, Google AI Studio, Google Vertex AI, xAI and Groq. You load dollar credits onto your Cloudflare account, send an ordinary HTTPS request to `api.cloudflare.com` carrying one Cloudflare API token, name a model as `provider/model`, and Cloudflare authenticates to the upstream provider, pays them, and deducts the cost from your credit balance. No `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` exists anywhere in the request path. Cloudflare's words: "Both deduct credits from your account automatically without requiring provider API keys."

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Terms this page uses

| Term | Meaning |
| --- | --- |
| Unified Billing | Cloudflare authenticates and pays the provider; you pay Cloudflare. Appears as `wholesale: true` in the gateway API and log rows. |
| BYOK | Bring Your Own Keys — your provider key is stored in Cloudflare Secrets Store and forwarded; the provider bills you. |
| AI Gateway | The proxy in front of the model call: logging, caching, retries, rate limits, spend limits. Free on all plans. Setup: [/a/cloudflare-ai-gateway-setup](/a/cloudflare-ai-gateway-setup). |
| Authenticated gateway | A gateway with its `authentication` setting on, which then demands a Cloudflare API token on every request. |
| Credits | Prepaid dollars on the Cloudflare account. Bought at a 5% surcharge; spent at the provider's own per-token rate. |
| Neurons | The unit Workers AI bills in, $0.011 per 1,000. A separate ledger from credits. |

## The 5% lands on the money, not on the traffic

Cloudflare states it in one sentence: "A 5% fee is applied to all credits purchased through Unified Billing. For example, a $100 credit purchase will result in a $105 charge. Inference pricing from providers is passed through with no markup — you pay the same per-token rates as you would directly with the provider."

That is a surcharge at top-up, not a per-request markup. Nothing you change inside a request moves it, because every dollar you eventually spend on tokens cost $1.05 to acquire.

Two public statements about that number disagree; both are printed here rather than reconciled away. On Hacker News, **yencabulator** corrected a commenter who had called Cloudflare's gateway the free option: "Free? They take the same 5% fee as OpenRouter does." Right about Cloudflare, imprecise about OpenRouter. OpenRouter's FAQ uses the same shape — it "charges a fee when you purchase credits" and passes provider pricing through "without any markup" — but the rate rendered on that page is **5.5% with a $0.80 minimum** for cards, 5% for crypto. On $100 that is $105.00 against $105.50; on $20, $21.00 against $21.10. Same mechanism, different price, and "the same 5%" is close rather than exact.

The number was hard to find at all, which is why it was disputed. **bm-rf**, reading the launch docs: "Not seeing any pricing info on the models[1] page. Wonder how much of a lift this is over paying providers directly. Perhaps Cloudflare is doing this at cost? Also interesting that zero data retention is not on by default". **6thbit**, same thread: "i wonder about their princing and potential markup on top of token usage?i presume they wont let you \"manage all your AI spend in one place\" for free." Both were guessing. The 5% is on the Unified Billing feature page, not on the model catalogue they were reading.

## The arithmetic for 100 million input and 20 million output tokens a month

Assumptions: one calendar month, 100,000,000 input tokens, 20,000,000 output tokens, no cached input, no batch discount, one authenticated gateway, list prices as of 2026-07-26.

`openai/gpt-4.1-mini` is $0.40 per million input and $1.60 per million output. Input 100 × $0.40 = $40.00. Output 20 × $1.60 = $32.00. Inference $72.00. BYOK pays OpenAI $72.00 and Cloudflare nothing, because AI Gateway's core features are free. Unified Billing deducts $72.00 of credits, and acquiring $72.00 of credits costs $72.00 × 1.05 = $75.60.

`anthropic/claude-sonnet-5` is $2 / $10 per million today — Anthropic footnotes it as "Introductory pricing of $2 / $10 per MTok applies to Claude Sonnet 5 through August 31, 2026" — and "$3 / input MTok $15 / output MTok" after that.

| Route | Inference | Cloudflare fee | You pay |
| --- | --- | --- | --- |
| `openai/gpt-4.1-mini`, BYOK | $72.00 | $0.00 | **$72.00** |
| `openai/gpt-4.1-mini`, Unified Billing | $72.00 | $3.60 | **$75.60** |
| `anthropic/claude-sonnet-5` intro, BYOK | $400.00 | $0.00 | **$400.00** |
| `anthropic/claude-sonnet-5` intro, Unified Billing | $400.00 | $20.00 | **$420.00** |
| `anthropic/claude-sonnet-5` after 2026-08-31, Unified Billing | $600.00 | $30.00 | **$630.00** |

Sonnet rows: 100 × $2 = $200 plus 20 × $10 = $200 gives $400; 100 × $3 = $300 plus 20 × $15 = $300 gives $600. The fee column is the inference column × 0.05 every time. That is the whole pricing model.

A Workers AI model has no row here. Cloudflare: "Workers AI models (models prefixed with `@cf/`) routed through AI Gateway are not charged via Unified Billing. These models are billed through Workers AI pricing instead" — "$0.011 per 1,000 Neurons". A mixed setup produces two line items by design. Which of those models are worth pointing a coding agent at: [/a/workers-ai-coding-models](/a/workers-ai-coding-models).

## Five things that must be true before a request bills

1. **Credits.** Dashboard → **AI Gateway** → the **Credits Available** card, top right → **Manage** → **Top-up credits** → amount → **Confirm and pay**. A payment method is required first. Optional: **Setup auto top-up credits**, with a threshold and a recharge amount.
2. **A gateway with authentication on.** That gateway's **Settings** → **Create authentication token** (a token with the required `Run` permissions, shown once) → back on the settings page, toggle **Authenticated Gateway** on. Skipping this is the 402 below.
3. **An API token.** **My Profile → API Tokens → Create Token → Create Custom Token**, with the account-scoped permissions **AI Gateway – Run** (send inference) and **AI Gateway – Read** (list gateways, read logs). Add **AI Gateway – Edit** only to change the gateway's zero-data-retention default over the API. Cloudflare's warning: "The `AI Gateway Read`, `Run`, and `Edit` permissions cannot be restricted to a single gateway — unlike R2, which supports per-bucket scoping. Any token with `AI Gateway Run` can send requests through every gateway in the account."
4. **The account id.** The 32-character hex string after `dash.cloudflare.com/` in the dashboard URL, or from `wrangler whoami`. It is the `{account_id}` path segment below.
5. **A `provider/model` id.** `openai/gpt-4.1-mini`, `anthropic/claude-sonnet-5`, `google/gemini-3-flash`, `xai/grok-3`.

```bash
curl -X POST "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai/v1/chat/completions" \
  -H "Authorization: Bearer <TOKEN>" \
  -H "cf-aig-gateway-id: default" \
  -H "Content-Type: application/json" \
  --data '{"model":"openai/gpt-4.1-mini","max_tokens":16,
           "messages":[{"role":"user","content":"Reply with the single word: ok"}]}'
```

Omitting `cf-aig-gateway-id` routes third-party requests through the account's default gateway. Workers AI `@cf/` requests always require the header.

## The 402 has one cause, and the docs table predicts the opposite

```json
{"errors":[{"message":"Gateway authentication is required to use unified billing. Enable authentication on your gateway or provide your own API key (BYOK).","code":2021}],"success":false,"result":{},"messages":[]}
```

One condition produces it: a `provider/model` request aimed at a gateway whose `authentication` is `false`. A valid token does not help. A loaded balance does not help. Below, two requests seconds apart on an account with `cloud-kernel` (`authentication: false`) and `default` (`authentication: true`) — same token, same body, same model, one header value different.

```bash
# Change only the gateway id between these two runs.
for GW in cloud-kernel default; do
  curl -s -w " <- %{http_code} on $GW\n" -X POST \
    "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai/v1/chat/completions" \
    -H "Authorization: Bearer <TOKEN>" -H "cf-aig-gateway-id: $GW" \
    -H "Content-Type: application/json" \
    --data '{"model":"openai/gpt-4.1-mini","max_tokens":16,"messages":[{"role":"user","content":"Reply with the single word: ok"}]}'
done
```

`cloud-kernel` returned **402** with the body above. `default` returned **200**: `{"id":"id-1785040573332","object":"chat.completion","model":"openai/gpt-4.1-mini","choices":[{"index":0,"message":{"role":"assistant","content":"ok"}}],"usage":{"prompt_tokens":14,"completion_tokens":1,"total_tokens":15}}`.

| Gateway `authentication` | Request | Result |
| --- | --- | --- |
| `false` | `provider/model` | **402, code 2021** |
| `false` | `@cf/vendor/model` | 200 — Workers AI is not Unified Billing |
| `true`, token present | `provider/model` | 200 |
| `true`, token absent | anything | 401 — an auth failure, not a billing failure |
| either, BYOK key stored | `provider/model` | 200, provider bills you. BYOK's own prerequisite is still an authenticated gateway. |

Two things make this expensive. The same `@cf/` call keeps working on the unauthenticated gateway, so it reads as a model problem. And the Authenticated Gateway page's own behaviour table says the opposite: for `Authentication Setting: Off` with `No header` it states "Unauthenticated gateway — Request succeeds". True for Workers AI and for BYOK; not true for Unified Billing, and nothing on that page says so.

**pemontto** filed the narrower version on Cloudflare's own repository, `cloudflare/ai` issue 548, 2026-05-30: "POST to `/ai/v1/responses` with Unified Billing auth (`Authorization: Bearer {CF_API_TOKEN}` plus `cf-aig-gateway-id: {authenticated_gateway_id}`) returns HTTP 402 even though the gateway has `authentication: true`." The report carries a full curl reproduction, confirms `authentication: true` by reading `GET /ai-gateway/gateways/{GW}`, and shows `/ai/v1/chat/completions` returning 200 on identical headers across `openai/gpt-5.4`, `gpt-5.4-mini` and `gpt-5.5`. **Status on 2026-07-26: open, zero comments.** The same shape tried here — `POST /ai/v1/responses`, `openai/gpt-4.1-mini`, authenticated gateway — returned **200** with a complete Responses envelope. The endpoint-specific 402 did not reproduce on this account and model. Both results stand; nobody has closed the issue.

## `anthropic/*` gets Anthropic's validation rules, whatever the endpoint is called

`/ai/v1/chat/completions` is an OpenAI-shaped endpoint. The shape is a translation layer, not a guarantee. When the id starts with `anthropic/`, the request lands on Anthropic's backend under Anthropic's schema — which has no `system` role inside `messages`, only a top-level `system` field ([/a/what-is-the-anthropic-messages-api](/a/what-is-the-anthropic-messages-api)).

**pmonte**, `anomalyco/opencode` issue 32951, 2026-06-19: "Cloudflare AI Gateway Unified Billing routes requests to Anthropic's backend. When the model is `anthropic/*`, the gateway applies Anthropic's validation rules, which **do not accept `role: \"system\"` inside the `messages` array**." Their client put the system prompt at `messages[0]`; every request failed.

Reproduced against `anthropic/claude-sonnet-5` on an authenticated gateway. The body that fails, and its exact answer:

```json
{"model":"anthropic/claude-sonnet-5","max_tokens":16,
 "messages":[{"role":"system","content":"You are terse."},
             {"role":"user","content":"Reply with the single word: ok"}]}
```
```
HTTP 400
{"errors":[{"message":"Model execution failed (User Input Error): Invalid value at messages[0].role: Invalid option: expected one of \"user\"|\"assistant\"","code":7003}],"success":false,"result":{},"messages":[]}
```

Two bodies work. Drop the system message and keep `/ai/v1/chat/completions`:

```json
{"model":"anthropic/claude-sonnet-5","max_tokens":16,
 "messages":[{"role":"user","content":"Reply with the single word: ok"}]}
```

Or keep the system prompt and move to `/ai/v1/messages`, Anthropic's own shape:

```bash
curl -X POST "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai/v1/messages" \
  -H "Authorization: Bearer <TOKEN>" -H "cf-aig-gateway-id: default" \
  -H "Content-Type: application/json" \
  --data '{"model":"anthropic/claude-sonnet-5","max_tokens":16,"system":"You are terse.",
           "messages":[{"role":"user","content":"Reply with the single word: ok"}]}'
```
```json
{"id":"msg_011CdQ3qJ4WRi3L5nnG3cdc3","type":"message","role":"assistant",
 "content":[{"type":"text","text":"ok"}],"model":"claude-sonnet-5","stop_reason":"end_turn",
 "usage":{"input_tokens":22,"output_tokens":4},"gatewayMetadata":{"keySource":"Unified"}}
```

`"keySource":"Unified"` is the response naming the credential that paid — the cheapest live confirmation that a call billed through credits rather than a stored key.

Prefer the second. The first succeeds and still misleads: the measured 200 from `anthropic/claude-sonnet-5` on `/ai/v1/chat/completions` was `{"index":0,"message":{"role":"assistant","refusal":null},"logprobs":null,"finish_reason":"stop"}` — **no `content` field at all** — with `usage` reported as `{"input_tokens":16,"output_tokens":4}` instead of OpenAI's `prompt_tokens`/`completion_tokens`. A client reading `choices[0].message.content` gets an empty answer and no error to explain it. This behaviour is not in the documentation.

The mismatch bites adapters from the other side too. **just-be-dev**, `withastro/flue` issue 327, 2026-06-20, stated the motive first: "have been leaning into cloudflare's AI gateway unified billing so that I don't have to provision keys for openai/anthropic directly. Ran into a bit of an issue where using anthropic's models in this way doesn't quite work." The adapter formatted every `cloudflare/...` request as OpenAI chat-completions — correct for `cloudflare/@cf/...`, wrong for `cloudflare/anthropic/claude-sonnet-4.6`.

## Model ids and endpoints decide the ledger

| Id shape | Example | Billed as | `cf-aig-gateway-id` |
| --- | --- | --- | --- |
| `provider/model` | `openai/gpt-4.1-mini`, `anthropic/claude-sonnet-5`, `google/gemini-3-flash`, `xai/grok-3` | Unified Billing credits, 5% surcharge at top-up | optional; defaults to the account default gateway |
| `@cf/vendor/model` | `@cf/moonshotai/kimi-k2.6`, `@cf/meta/llama-3.3-70b-instruct-fp8-fast` | Workers AI Neurons, $0.011 / 1,000 | **required** |
| `provider/model` with a stored key | any of the above | the provider bills you; Cloudflare bills nothing | required, gateway must be authenticated |

Four endpoints accept Unified Billing traffic: `POST /ai/run` (Cloudflare envelope, `{model, input}`), `POST /ai/v1/chat/completions` (OpenAI chat completions), `POST /ai/v1/responses` (OpenAI Responses), `POST /ai/v1/messages` (Anthropic Messages). Only the last refuses Workers AI ids outright — Cloudflare: "Workers AI models (`@cf/`) do not support this schema." `/ai/v1/responses` accepts `@cf/` ids model-by-model.

## What is covered, and the four places the documentation stops

Providers on the HTTP API: OpenAI, Anthropic, Google AI Studio, Google Vertex AI, xAI, Groq. Zero data retention is a Unified-Billing-only control — "This setting only applies to Unified Billing requests that use Cloudflare-managed credentials. It does not apply to BYOK or other AI Gateway requests" — off by default, supported for OpenAI and Anthropic only, and where a provider lacks it "AI Gateway falls back to the standard (non-ZDR) Unified Billing configuration" without saying so at request time. Set it per gateway under **Settings → Zero Data Retention (ZDR)** or per request with `cf-aig-zdr: true`. It is not logging: "ZDR does not control AI Gateway logging." Spend limits cover both lanes — "Spend limits apply to both Unified Billing requests and BYOK requests for models with known pricing" — block with `429`, and are "eventually consistent", so "a burst of concurrent requests can briefly exceed the limit before enforcement catches up."

Four silences:

- **The 402.** The Unified Billing page lists an authenticated gateway as a prerequisite and never names the status code or the error string; the Authenticated Gateway page's behaviour table contradicts it for this lane.
- **The negative balance.** In full: "In rare instances, your credit balance may go negative. If this happens, Cloudflare will charge the payment method on file for the outstanding amount. Charges occur at the beginning of each month for the previous month." No cap is documented.
- **The `cost` field's precision.** Present on every log row, the basis of spend limits, rounding unspecified.
- **The `anthropic/*` envelope on the OpenAI endpoint.** Measured above, described nowhere.

## What one billed request leaves behind

`GET /accounts/<ACCOUNT_ID>/ai-gateway/gateways/default/logs` returns the receipt for the calls made above.

| `model` | `tokens_in` / `tokens_out` | `cost` | `authentication` | `wholesale` | `byok` |
| --- | --- | --- | --- | --- | --- |
| `anthropic/claude-sonnet-5` | 22 / 4 | `0.00008400000000000001` | `true` | `true` | `null` |
| `anthropic/claude-sonnet-5` | 16 / 4 | `0.000072` | `true` | `true` | `null` |
| `openai/gpt-4.1-mini` | 14 / 2 | `0.0000088` | `true` | `true` | `null` |

`wholesale: true` marks the row as Unified Billing; `byok: null` confirms no stored key was used. Test the no-markup claim against those three numbers:

- `openai/gpt-4.1-mini` at OpenAI's published $0.40 and $1.60 per million: (14 × 0.40 + 2 × 1.60) ÷ 1,000,000 = 8.8 ÷ 1,000,000 = **$0.0000088**. The logged value, to the digit.
- `anthropic/claude-sonnet-5`, 22 in and 4 out, at Anthropic's introductory $2 and $10: (44 + 40) ÷ 1,000,000 = **$0.000084**. The logged value. At the standard $3 / $15 the same row would be $0.000126, so the log is charging the introductory rate.
- The 16-in row: (32 + 40) ÷ 1,000,000 = **$0.000072**. The logged value.

Two providers, three rows, exact agreement. No markup per request; the 5% is entirely at top-up. A fourth call, `@cf/meta/llama-3.3-70b-instruct-fp8-fast` through the **unauthenticated** gateway, returned 200 and reported `"neurons":1.529652714729309` instead of a dollar cost — 1.5297 ÷ 1,000 × $0.011 = **$0.0000168**, in the other ledger.

## Six routes, and the one to take

| Route | Take it when | Cost of taking it |
| --- | --- | --- |
| **Unified Billing** | The number of provider accounts is the problem: one token, one invoice, one place to set spend limits, no provider key in the code | 5% on credits, and a hard dependency on Cloudflare for reachability |
| **BYOK on the same gateway** | Volume is high, or a provider contract or committed-spend discount already exists that the 5% would sit on top of | Key custody returns, one bill per provider; still needs an authenticated gateway |
| **Direct provider keys, no gateway** | A single-provider service where the gateway earns nothing | No unified logs, no cross-provider spend limit, no cache, per-provider rotation |
| **Workers AI `@cf/`** | The model you want is in Cloudflare's own catalogue and you want no third-party relationship | A much smaller catalogue than the frontier providers' |
| **OpenRouter** | Breadth of model coverage matters more than price | 5.5% + $0.80 on a card top-up, and another vendor relationship |
| **Bedrock or Vertex** | Procurement, not the model, is the constraint — spend must land on an AWS or Google contract | Vertex is already reachable *through* Unified Billing, so this is only distinct when the billing relationship is the requirement |

Two merged pull requests show what people actually adopt this for, and it is not the price. **jeremyhart** rewrote a connector to drop a confusing "Provider API Key" plus separate "Gateway Token" pair for a single Cloudflare API token with AI Gateway Run permission: "Cloudflare authenticates and bills the upstream provider, so no per-provider keys are needed." **kyleboas** made the mirror-image change on a `callLLM` that had been attaching an upstream `Authorization` header on every gateway request and injecting `ANTHROPIC_API_KEY`/`OPENAI_API_KEY` into agent inputs: "Cloudflare Unified Billing lets the gateway handle upstream authentication and billing, so provider API keys should be optional when routing through the AI Gateway."

**Verdict:** route through Unified Billing until inference spend is large enough that 5% costs more than running key rotation and reconciling several provider invoices. At $72 a month the fee is $3.60 and the decision is not close. At $50,000 a month it is $2,500, and BYOK on the same gateway removes the fee while keeping every gateway feature. The break-even is an operations question, and moving between the two is a stored secret plus a gateway setting, not a rewrite.

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| `402` + `Gateway authentication is required to use unified billing`, code `2021` | The target gateway has `authentication: false` | Gateway **Settings → Create authentication token → Authenticated Gateway** on, or point `cf-aig-gateway-id` at a gateway that already is |
| `@cf/` models work, `provider/model` returns 402 on the same gateway | Workers AI does not use Unified Billing, so the authentication setting does not affect it | Same fix. A working `@cf/` call is not evidence the gateway is configured |
| `400` + `Invalid value at messages[0].role: Invalid option: expected one of "user"\|"assistant"` | An OpenAI-shaped body with `role: "system"` sent to an `anthropic/*` model | Move the system prompt to a top-level `system` field and call `/ai/v1/messages`, or drop the system message |
| `anthropic/*` returns 200 but `choices[0].message.content` is missing | The OpenAI envelope from an Anthropic model omits `content` and uses `input_tokens`/`output_tokens` | Use `/ai/v1/messages` and read `content[0].text` |
| `404` + `Model not found: <id>` | An id that is not in the catalogue, often a stale version suffix | `anthropic/claude-sonnet-4-5` 404s on this account; `anthropic/claude-sonnet-5` does not. Check the catalogue |
| `410` + `Model has been deprecated` | A retired Workers AI model | Pick a current id from `GET /accounts/<ACCOUNT_ID>/ai/models/search` |
| `401` on a request that used to work | Missing or revoked token on an authenticated gateway | Reissue with **AI Gateway – Run**; every method needs it, `GET` included |
| `429` with spend under budget | Spend limits are eventually consistent under concurrency, or a rate-limit rule exists | Read the gateway config — a rate rule and a spend rule both return 429 |
| `405` on `GET /ai/v1/models` | That path is not a listing endpoint on the Cloudflare API | Use `GET /accounts/<ACCOUNT_ID>/ai/models/search` for `@cf/` ids and the model catalogue for `provider/model` |
| Requests bill but no logs appear | `collect_logs` off on the gateway, or `cf-aig-collect-log: false` on the request | Turn logging on in the gateway settings; billing and logging are independent |
| Balance went negative | Documented behaviour; the card on file is charged for the shortfall at the start of the next month | Configure auto top-up with a threshold above one day's spend |

## Rerun every number here

All of it was measured on 2026-07-26 against a production account with two gateways, `cloud-kernel` (`authentication: false`) and `default` (`authentication: true`), using a token with AI Gateway Run and Read. Total spend for the whole set: under two hundredths of a cent.

```bash
# 1. Ground truth for the 402 — which gateways are authenticated
curl -s "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai-gateway/gateways?per_page=50" \
  -H "Authorization: Bearer <TOKEN>" | jq '.result[] | {id, authentication, is_default}'

# 2. The 402 / 200 pair: the loop in "The 402 has one cause" above
# 3. The shape trap: the three bodies in "anthropic/* gets Anthropic's validation rules" above

# 4. The receipt, including the cost field the arithmetic is checked against
curl -s "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai-gateway/gateways/default/logs?per_page=6&order_by=created_at&order_by_direction=desc" \
  -H "Authorization: Bearer <TOKEN>" \
  | jq '.result[] | {model, tokens_in, tokens_out, cost, authentication, wholesale, byok}'
```

Substitute your own account id and token. The `cost` values will differ with the models you call; the reconciliation is the same — multiply `tokens_in` and `tokens_out` by the provider's published per-million rates and compare. A disagreement on your account is the interesting result, because that one number is what the no-markup claim rests on.

An MIT-licensed Worker that speaks the Anthropic Messages API and forwards to this surface, including the `anthropic/*` lane and the `cf-aig-gateway-id` handling above, is at [github.com/redacted/claude-code-cloudflare-gateway](https://github.com/redacted/claude-code-cloudflare-gateway) — `tools/contract-test.mjs` runs 21 wire checks, `tools/capture-gateway.mjs` logs exactly what a client sends. The build that uses it: [/a/claude-code-on-cloudflare-ai-gateway](/a/claude-code-on-cloudflare-ai-gateway).

## Sources

1. Cloudflare AI Gateway — Unified Billing — https://developers.cloudflare.com/ai-gateway/features/unified-billing/
2. Cloudflare AI Gateway — Authenticated Gateway — https://developers.cloudflare.com/ai-gateway/configuration/authentication/
3. Cloudflare AI Gateway — REST API — https://developers.cloudflare.com/ai-gateway/usage/rest-api/
4. Cloudflare AI Gateway — Pricing — https://developers.cloudflare.com/ai-gateway/reference/pricing/
5. Cloudflare AI Gateway — Spend limits — https://developers.cloudflare.com/ai-gateway/features/spend-limits/
6. Cloudflare AI Gateway — BYOK (Store Keys) — https://developers.cloudflare.com/ai-gateway/configuration/bring-your-own-keys/
7. Cloudflare Workers AI — Pricing — https://developers.cloudflare.com/workers-ai/platform/pricing/
8. OpenAI — API pricing — https://developers.openai.com/api/docs/pricing
9. OpenRouter — FAQ, what are the fees for using OpenRouter — https://openrouter.ai/docs/faq
10. Anthropic — Model overview and pricing — https://platform.claude.com/docs/en/about-claude/models/overview
11. AI Gateway REST: /ai/v1/responses rejects Unified Billing on an authenticated gateway — https://github.com/cloudflare/ai/issues/548
12. Cloudflare AI Gateway (Unified Billing) rejects `role: "system"` in messages array when proxying Anthropic models — https://github.com/anomalyco/opencode/issues/32951
13. Support anthropic models via cloudflare's unified billing — https://github.com/withastro/flue/issues/327
14. Comment on "OpenRouter raises $113M Series B" — the Cloudflare gateway is not free — https://news.ycombinator.com/item?id=48346648
15. Comment on "Cloudflare's AI Platform" — no pricing on the models page — https://news.ycombinator.com/item?id=47793121
16. Comment on "Cloudflare's AI Platform" — markup on token usage — https://news.ycombinator.com/item?id=47793207
17. Simplify Cloudflare AI Gateway to Unified Billing; fix migration guard — https://github.com/jeremyhart/claworc/pull/6
18. Allow Cloudflare Unified Billing without upstream provider keys — https://github.com/kyleboas/blob/pull/124
19. claude-code-cloudflare-gateway — a Worker that speaks Anthropic Messages and forwards to this surface — https://github.com/redacted/claude-code-cloudflare-gateway
20. Measured per-turn cost of coding traffic through the same gateway — https://miscsubjects.com/a/claude-code-on-cloudflare-ai-gateway
21. First-party: the 402 reproduced against two gateways, 2026-07-26 — https://miscsubjects.com/a/cloudflare-unified-billing
22. First-party: the anthropic/* shape trap and both working bodies, 2026-07-26 — https://miscsubjects.com/a/cloudflare-unified-billing
23. First-party: gateway log rows with the cost field, reconciled against published rates — https://miscsubjects.com/a/cloudflare-unified-billing
24. First-party: /ai/v1/responses returned 200, so issue 548 did not reproduce here — https://miscsubjects.com/a/cloudflare-unified-billing
25. First-party: a Workers AI model on the unauthenticated gateway, billed in Neurons — https://miscsubjects.com/a/cloudflare-unified-billing


---

# How to create a Cloudflare AI Gateway, with authentication on

slug: cloudflare-ai-gateway-setup · https://miscsubjects.com/a/cloudflare-ai-gateway-setup · tags: tooling, cloudflare, ai-gateway · updated 2026-07-26T03:31:34.440Z

An AI Gateway is a URL you send AI requests to instead of sending them to OpenAI, Anthropic, xAI or Cloudflare's own models directly; Cloudflare forwards the request, returns the provider's answer unchanged, and keeps a row recording the model, the token counts, an estimated cost, the duration and the status code. It is not a model, not a router that picks a model for you, and not a billing account on its own — it is a proxy with a ledger, and everything else it does (caching, retries, rate limits, spend caps) is switched on per gateway or per request.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## The gateway called `default` exists before you create anything

If a request omits the gateway id, the id `default` is used, and if no gateway by that name exists it is created on the first authenticated request. The settings it is born with are not the ones most guides assume:

| Setting on the auto-created `default` gateway | Value |
| --- | --- |
| Authentication | On |
| Log collection | On |
| Caching | Off (TTL of 0) |
| Rate limiting | Off |

Caching is off, so any claim that the gateway will cut your bill by serving repeats describes a feature you have not enabled. Authentication is on, so the first thing that happens to a client which cannot send a second HTTP header is a `401`. Auto-creation applies only to the id `default`; any other id must be created first.

[[embed:source:s1]]

## Prerequisites, with the exact labels

1. **A Cloudflare account.** The free plan is enough — AI Gateway's core features cost nothing on every plan.
2. **Your account id.** Every dashboard URL is `https://dash.cloudflare.com/<ACCOUNT_ID>/...`; the 32-character hex string after the hostname is it. `wrangler whoami` prints the same value. It goes in every request URL.
3. **Where the product lives.** Sidebar → **AI** → **AI Gateway**. Direct link: `https://dash.cloudflare.com/?to=/:account/ai/ai-gateway`.
4. **An API token**, from `https://dash.cloudflare.com/profile/api-tokens` → **Create Token** → **Create Custom Token** → **Get started**. Under **Permissions** the rows read exactly `Account` · `AI Gateway` · `Read | Edit | Run`. Under **Account Resources** pick your account, then **Continue to summary** → **Create Token**. It is displayed once.

The three permissions are not interchangeable. `Read` lists gateways and reads settings and logs through the API. `Edit` creates, updates and deletes gateways, and is required to set `cache_ttl` or rate limits by API rather than by clicking. `Run` sends inference through `gateway.ai.cloudflare.com`, and is what the `cf-aig-authorization` header carries. None can be narrowed to one gateway:

> The `AI Gateway Read`, `Run`, and `Edit` permissions cannot be restricted to a single gateway — unlike R2, which supports per-bucket scoping. Any token with `AI Gateway Run` can send requests through every gateway in the account, including any configured with stored provider keys through Bring Your Own Keys (BYOK), consuming those credentials.

A leaked `Run` token is therefore an account-wide credential that can spend any provider key you have stored. Isolation between tenants means separate Cloudflare accounts, or a Worker binding instead of a URL.

[[embed:source:s2]]

## Creating one: five clicks, or one POST

**Dashboard.** **AI** → **AI Gateway** → **Create Gateway** → type a **Gateway name** (64-character limit; the name becomes the gateway id and appears in every request URL) → **Create**.

**API**, with a token carrying `AI Gateway` · `Edit`:

```bash
curl -X POST "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai-gateway/gateways" \
  -H "Authorization: Bearer <TOKEN>" \
  -H "content-type: application/json" \
  -d '{"id":"coding","cache_ttl":0,"collect_logs":true,
       "rate_limiting_interval":60,"rate_limiting_limit":120,"rate_limiting_technique":"sliding"}'
```

What comes back is the gateway's full configuration, the same object `GET /ai-gateway/gateways` returns. This is the live record for the authenticated gateway on the account measured throughout this page, account id removed:

```json
{
  "id": "default",
  "created_at": "2026-06-11 17:31:56",
  "modified_at": "2026-07-26 02:19:03",
  "rate_limiting_interval": 60,
  "rate_limiting_limit": 120,
  "rate_limiting_technique": "sliding",
  "cache_ttl": 0,
  "log_management": 10000000,
  "log_management_strategy": "DELETE_OLDEST",
  "authentication": true,
  "collect_logs": true,
  "cache_invalidate_on_update": false,
  "logpush": false,
  "wholesale": true,
  "zdr": false,
  "store_id": "",
  "is_default": true,
  "workers_ai_billing_mode": "postpaid",
  "retry_max_attempts": null,
  "retry_delay": null,
  "retry_backoff": null
}
```

Read it as a checklist. `authentication: true`: every request needs a credential. `cache_ttl: 0`: nothing is cached unless a request asks. `rate_limiting_limit: 120` with `interval: 60`, `technique: "sliding"`: no more than 120 requests in any trailing 60 seconds. `log_management: 10000000` with `DELETE_OLDEST`: the ten-millionth log evicts the first rather than stopping collection.

[[embed:source:s16]]

## Three URL shapes, and two of them are deprecated

| Shape | URL | Status | Auth |
| --- | --- | --- | --- |
| Provider-native | `https://gateway.ai.cloudflare.com/v1/<ACCOUNT_ID>/<GATEWAY_ID>/<PROVIDER>/<provider path>` | Current | Provider's own key, plus `cf-aig-authorization` when the gateway is authenticated |
| OpenAI-compatible on the gateway host | `https://gateway.ai.cloudflare.com/v1/<ACCOUNT_ID>/<GATEWAY_ID>/compat/chat/completions` | Deprecated, still works | Same |
| Cloudflare REST API | `https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai/v1/chat/completions` | Current, recommended for new work | `Authorization: Bearer <TOKEN>` |

The old **Universal Endpoint** — `POST https://gateway.ai.cloudflare.com/v1/<ACCOUNT_ID>/<GATEWAY_ID>` with an array of provider objects as the body, each carrying `provider`, `endpoint`, `headers` and `query`, tried in order as fallbacks — is deprecated too; fallback and retry work now points at Dynamic Routing.

Model support across the four REST endpoints is not uniform:

| Endpoint | Body format | Third-party models | Workers AI `@cf/` models |
| --- | --- | --- | --- |
| `POST /ai/run` | `{"model":…,"input":{…}}` | Yes | Yes, with `cf-aig-gateway-id` |
| `POST /ai/v1/chat/completions` | OpenAI chat completions | Yes | Yes, with `cf-aig-gateway-id` |
| `POST /ai/v1/responses` | OpenAI Responses | Yes | Model-dependent |
| `POST /ai/v1/messages` | Anthropic Messages | Yes | **No** |

Third-party models are named `author/model` (`openai/gpt-4.1`, `xai/grok-3`). Cloudflare-hosted models are `@cf/author/model` and reach a gateway only when the request carries `cf-aig-gateway-id`; without it the call still runs, it is simply not logged, cached or rate-limited by any gateway.

[[embed:source:s3]]

## A token the REST API accepts is refused by the provider-native host

The two hosts do not check the same thing, and the failure gives no hint.

**A. Provider-native host, authenticated gateway, no `cf-aig-authorization`:**

```bash
curl -sS -X POST "https://gateway.ai.cloudflare.com/v1/<ACCOUNT_ID>/default/workers-ai/v1/chat/completions" \
  -H "Authorization: Bearer <TOKEN>" -H 'content-type: application/json' \
  -d '{"model":"@cf/moonshotai/kimi-k2.7-code","max_tokens":8,"messages":[{"role":"user","content":"hi"}]}'
```

```json
{"success":false,"result":[],"messages":[],"error":[{"code":2009,"message":"Unauthorized"}],
 "name":"AiGatewayError","httpCode":401,"internalCode":2009,"message":"Unauthorized"}
```

**B.** The same call with `-H "cf-aig-authorization: Bearer <TOKEN>"` added returns a byte-identical body. The token was a valid Cloudflare token lacking `AI Gateway` · `Run`, so a missing header and an under-permissioned token are indistinguishable from the response. Check the header first, then the permissions.

**C. REST host, same token, same gateway, same model, no `cf-aig-authorization` at all:**

```bash
curl -sS -X POST "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai/v1/chat/completions" \
  -H "Authorization: Bearer <TOKEN>" -H "cf-aig-gateway-id: default" \
  -H 'content-type: application/json' \
  -d '{"model":"@cf/moonshotai/kimi-k2.7-code","max_tokens":12,"messages":[{"role":"user","content":"Say OK"}]}'
```

```json
{"id":"id-1785040709314","object":"chat.completion","created":1785040709,
 "model":"@cf/moonshotai/kimi-k2.7-code",
 "usage":{"prompt_tokens":10,"completion_tokens":12,"total_tokens":22,
          "prompt_tokens_details":{"cached_tokens":0},"neurons":5.227272987365723}}
```

HTTP 200, logged on the authenticated gateway. The rule: `AI Gateway` · `Run` is a requirement of the `gateway.ai.cloudflare.com` host, not of authenticated gateways as such.

[[embed:source:s17]]

## Authentication breaks every client whose whole config is a base URL and a key

With authentication on, a provider-native request must carry `cf-aig-authorization: Bearer <TOKEN>` alongside whatever credential the provider itself wants. Cloudflare's behaviour table:

| Authentication | Header | Result |
| --- | --- | --- |
| On | Present | Succeeds |
| On | Absent | Fails |
| Off | Present | Succeeds |
| Off | Absent | Succeeds |

A client whose entire configuration is a base URL and an API key has no slot for a second header. Cloudflare's own container-based coding agent hit exactly that:

> When Authenticated Gateway is enabled on the AI Gateway, all requests from Moltworker fail because the required `cf-aig-authorization` header is never sent.

The documented escape hatch — a Worker **binding**, pre-authenticated by account identity and needing no header — is unavailable when the calling binary lives inside a container that only received a base URL.

The neighbouring failure is the shape of the *first* header rather than the absence of the second. Anthropic's API wants `x-api-key`; a client hardcoding `Authorization: Bearer` gets a `401` that reads like a bad key:

> cc-switch currently sends `Authorization: Bearer <key>` for all upstream providers. Cloudflare AI Gateway requires `x-api-key: <anthropic_key>` instead, causing 401 errors when trying to use it as a proxy.

That project's fix: detect any base URL containing `gateway.ai.cloudflare.com`, switch to an `x-api-key` strategy, add an optional `cf-aig-authorization` from an environment variable. When choosing a client, that pair of features is what to look for.

[[embed:source:s10]]
[[embed:source:s13]]

## The `cf-aig-*` headers, their defaults, and what each changes

A request-level header always wins over the gateway setting; the gateway setting is the default when no header is sent.

| Header | Default | Observable effect |
| --- | --- | --- |
| `cf-aig-gateway-id` | none | Routes a REST API call through the named gateway. Required for `@cf/` models to be logged by a gateway at all. |
| `cf-aig-authorization` | none | `Bearer <TOKEN>` for the `gateway.ai.cloudflare.com` hosts when authentication is on. Missing or under-permissioned gives `401 code 2009`. |
| `cf-aig-metadata` | none | Up to 5 JSON entries stored on the log row, filterable in the dashboard, queryable as `metadataRaw`. |
| `cf-aig-cache-ttl` | gateway `cache_ttl`, `0` on a new gateway | Seconds to keep the response. Minimum 60, maximum one month. |
| `cf-aig-skip-cache` | `false` | `true` bypasses the cache for that request. |
| `cf-aig-cache-key` | none | Replaces the default hashed key. With no gateway caching enabled, such a response lives 5 minutes. |
| `cf-aig-max-attempts` / `cf-aig-retry-delay` / `cf-aig-backoff` | gateway retry settings, unset on a new gateway | Up to 5 attempts, delay up to 5000 ms, backoff `constant`, `linear` or `exponential`. On the last attempt the gateway waits however long the provider takes. |
| `cf-aig-request-timeout` | none | Milliseconds before the request errors or falls back. Measured from the first byte, so a slow stream does not trip it. |
| `cf-aig-collect-log` | gateway `collect_logs` | `false` drops the whole row, metrics included. |
| `cf-aig-collect-log-payload` | `true` when logging is on | `false` keeps the metrics and drops the stored request and response bodies. |
| `cf-aig-custom-cost` | none | Overrides the cost figure on the row with your negotiated rates. |
| `cf-aig-cache-status` (response) | — | `HIT` or `MISS`. See the caveat below. |
| `cf-aig-step` (response) | — | Which fallback step answered; `0` is the primary. |

[[embed:source:s4]]

## The cache key is a hash of the whole request, which is why real traffic never hits it

Cloudflare builds the key by concatenating provider, endpoint path, model, the provider authentication header and **the full request body**, then hashing with SHA-256. Any byte that moves, moves the key.

An operator who put production support-bot traffic through a gateway measured the consequence:

> I was routing support-bot traffic through Cloudflare AI Gateway and  noticed the cache hit rate was near zero despite the product being  mature.

He pulled 500 consecutive misses, stripped each to the user message, and found 89% mapped to fewer than 30 distinct intents. Request ids, trace ids, timestamps and session wrappers riding in the body moved the keys — not the questions. His answer was a Worker that canonicalises the request before the gateway sees it, with Vectorize for near-matches and a Durable Object per hot key.

The mechanism reproduces in five requests, same gateway, `cf-aig-cache-ttl: 300` on all five:

| Request | Body | Result |
| --- | --- | --- |
| 1 | `{"model":"@cf/moonshotai/kimi-k2.7-code","max_tokens":8,"messages":[{"role":"user","content":"cache probe alpha"}]}` | miss (first fill) |
| 2 | identical to 1 | **hit** |
| 3 | identical to 1 | **hit** |
| 4 | same user message, system message `requestId=<uuid>` | miss |
| 5 | same user message, system message `requestId=<uuid>` (a different uuid) | miss |

Two hits out of five, and both misses in the second pair carried a question the gateway had already answered. One 36-character field was enough.

Three things follow. Strip request ids, timestamps and session wrappers from the body, or move them into `cf-aig-metadata`, where they are logged but not hashed. Use `cf-aig-cache-key` to pin the key to the part of the request that determines the answer. Expect no semantic matching: Cloudflare says caching "applies only to identical requests" and that semantic search is future work.

One measured gap. On the `api.cloudflare.com` REST host the two cached responses came back **without** a `cf-aig-cache-status` header, though the documentation says to read that header to tell a hit from a miss. Their headers were `date`, `content-type`, `content-length`, `api-version`, `cf-ai-neurons`, `cf-auditlog-id`, `server` and `cf-ray` — no `cf-aig-*` at all — while the analytics records both as `cached: 1`. On that host the analytics is the only cache signal.

[[embed:source:s11]]
[[embed:source:s5]]
[[embed:source:s18]]

## Rate limiting is what stands between a leaked token and your balance

Because a `Run` token reaches every gateway on the account, and Unified Billing spends real credits, the gateway limit is the blast-radius control. **AI** → **AI Gateway** → your gateway → **Settings** → **Rate-limiting**, then set a count, a period, and fixed or sliding.

The two techniques differ. With ten requests per ten minutes starting at 12:00, a fixed window runs 12:00–12:10 then 12:10–12:20, so ten requests at 12:09 and ten more at 12:11 all succeed; a sliding window rejects the second batch because it counts the trailing ten minutes. Over the limit is `429 Too Many Requests` and the request is not forwarded.

The gateway measured here runs 120 per 60 seconds, sliding. Over the 30 days to 2026-07-26 that produced exactly one `429` in 5,252 requests across the account — no obstruction to normal work, and a cap of 172,800 requests a day on a runaway loop instead of no cap at all. Unified Billing adds a ceiling you cannot raise: 200 requests per 60 seconds per gateway.

[[embed:source:s6]]

## Logs keep the prompt and the completion, and they are how you audit spend

A row carries the user prompt, the model response, the provider, the timestamp, the status, token usage, cost, duration and the client's user agent. Bodies are stored unless the request sends `cf-aig-collect-log-payload: false`, which keeps the metrics and drops the text.

| Limit | Value |
| --- | --- |
| Logs stored, Workers Paid | 10,000,000 per gateway |
| Logs stored, Workers Free | 100,000 across all gateways |
| Size of one log | 10 MB |
| Log write rate | 500 per second per gateway |
| Custom metadata | 5 entries per request |
| Cacheable request size | 25 MB |
| Gateways per account | 10 free, 20 paid |

Retention is a count, not a clock: logs are kept until the gateway hits its storage limit, then either the oldest are deleted (`log_management_strategy: "DELETE_OLDEST"`) or new logs stop being saved.

Two ways to read them without the dashboard. The Logs API, `GET /accounts/<ACCOUNT_ID>/ai-gateway/gateways/<GATEWAY_ID>/logs`, needs `AI Gateway` · `Read`. The GraphQL dataset `aiGatewayRequestsAdaptiveGroups` needs only account analytics access and produced every measurement here:

```bash
curl -sS -X POST https://api.cloudflare.com/client/v4/graphql \
  -H "Authorization: Bearer <TOKEN>" -H 'content-type: application/json' \
  -d '{"query":"query { viewer { accounts(filter:{accountTag:\"<ACCOUNT_ID>\"}) {
        aiGatewayRequestsAdaptiveGroups(limit:20, filter:{date_geq:\"2026-07-26\", gateway:\"default\"}) {
          count dimensions { datetimeMinute model provider statusCode cached durationMs
                             tokensIn tokensOut cost } } } } }"}'
```

Six real rows from that gateway:

| Time (UTC) | Model | Provider | Status | Cached | Duration ms | Tokens in | Tokens out | Cost USD |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 2026-07-26T04:35 | `@cf/zai-org/glm-4.7-flash` | workers-ai | 200 | 0 | 7413 | 46 | 587 | 0.00023756 |
| 2026-07-26T04:35 | `@cf/zai-org/glm-5.2` | workers-ai | 200 | 0 | 2335 | 53 | 135 | 0.0006682 |
| 2026-07-26T04:35 | `minimax/m3` | minimax | 200 | 0 | 1145 | 217 | 68 | 0.00011934 |
| 2026-07-26T04:36 | `anthropic/claude-sonnet-4-5` | unknown | 500 | 0 | 677 | 0 | 0 | 0 |
| 2026-07-26T04:37 | `anthropic/claude-sonnet-5` | anthropic | 200 | 0 | 406 | 16 | 4 | 0.000072 |
| 2026-07-26T04:37 | `anthropic/claude-sonnet-5` | unknown | 400 | 0 | 316 | 0 | 0 | 0 |

Read the failures too. A model id that is not on the catalogue produces `provider: "unknown"`, a `500` and a cost of zero — an attempt logged that never reached a provider. The `400` on the last line is the same shape.

Metadata is stored verbatim, so send something useful. From the same gateway, session identifier removed:

```json
{"via":"claude-code","shim":"api/aig","model_asked":"kimi","session":"<REDACTED>","tools":9}
```

That is what makes cost attributable per agent instead of per account.

[[embed:source:s7]]
[[embed:source:s14]]

## What it costs, and the one number people report as wrong

The gateway is free on every plan: dashboard analytics, caching and rate limiting are core features at no charge, and persistent logs are included within the storage limits above. Logpush is Workers Paid only — 10 million requests a month, then $0.05 per million. Unified Billing adds 5% to credit purchases, so a $100 top-up is charged as $105, while per-token inference is the provider's own rate with no markup.

Measured spend on the authenticated gateway, 30 days to 2026-07-26: 107 requests, 651,894 input tokens, 8,342 output tokens, **$0.44830454**. The second gateway on the same account carried the bulk: 5,145 requests, $32.19921439. Both are the gateway's own cost column summed by GraphQL.

That column is where the complaint sits. Cloudflare labels it an estimate:

> The cost metric is an **estimation** based on the number of tokens sent and received in requests. While this metric can help you monitor and predict cost trends, refer to your provider's dashboard for the most **accurate** cost details.

A customer running production traffic on flagship image models says it is not merely imprecise:

> because Cloudflare AI Gateway is reporting inaccurate/wrong price for flagship models such as Nano Banana 2 and Nano Banana pro (I run production app using those). Been reporting it on discord and twitter, and they don't care.

Both can be true, and together they set the rule: use the cost column for trends and per-agent attribution, reconcile against the provider's invoice before billing anyone, and if you have negotiated rates send `cf-aig-custom-cost` so the column reflects your contract rather than list price.

[[embed:source:s8]]
[[embed:source:s9]]

## The hop costs less than the noise

No Cloudflare-published overhead figure exists; the circulating numbers are community estimates of roughly 10 to 50 ms, and cross-vendor benchmarks are not comparable because "almost every one of these benchmarks clocks proxy forwarding against a mock upstream, which removes the one variable that dominates real requests: the model provider's own response time." Measured here with one model, one prompt, `max_tokens: 1`, `cf-aig-skip-cache: true`, six `curl -w "%{time_total}"` runs each way, the only difference being the `cf-aig-gateway-id` header: median **0.741 s** through the gateway against **0.648 s** without, both arms spanning roughly 0.5 to 1.3 seconds. The 93 ms gap sits inside run-to-run variance at n=6 — evidence the hop is too small to separate from jitter, not evidence of a 93 ms tax.

[[embed:source:s15]]

## Where the traffic egresses is a failure mode nobody writes down

Cloudflare answers from the point of presence nearest the caller, and the provider sees the request coming from there. If the provider blocks a country Cloudflare has a PoP in, requests fail non-deterministically depending on where they land:

> this made Cloudflare's AI Gateway an unusable product, as they hilariously put a node in HK so you never know when your Anthropic request is randomly going to fail defeating the whole purpose behind the product.

No gateway setting pins egress. The mitigations are the REST API host, which terminates on Cloudflare's API rather than the anycast gateway edge; Unified Billing, so the provider sees Cloudflare as the customer; or a Worker with fixed placement calling the provider directly.

Against that, for people whose providers are not geo-blocking them the setup really is small:

> We used cloudflare's AI gateway which is pretty simple. Set one up, get the proxy URL and set it through the env var, very plug-and-play

Both reports concern the same product a year apart and neither cancels the other. The gateway is one environment variable of work and a real geographic gamble on Anthropic traffic.

[[embed:source:s12]]
[[embed:source:s19]]

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| `402` with `{"errors":[{"message":"Gateway authentication is required to use unified billing. Enable authentication on your gateway or provide your own API key (BYOK).","code":2021}]}` | A third-party model routed through a gateway with `authentication: false`. `@cf/` models on the same gateway keep working, which disguises it. | Turn **Authenticated Gateway** on, or supply your own provider key. |
| `401` with `{"code":2009,"message":"Unauthorized"}` from `gateway.ai.cloudflare.com` | Either no `cf-aig-authorization`, or a token without `AI Gateway` · `Run`. The body is identical either way. | Add the header; if it is already there, re-mint the token with `Run`. |
| `401` from a client that worked against Anthropic yesterday | The client sends `Authorization: Bearer` where the Anthropic-native path wants `x-api-key`. | Use a client that switches header shape on `gateway.ai.cloudflare.com`, or send `x-api-key` yourself. |
| A custom provider request reaches `/v1/<path>` upstream when `base_url` has no `/v1` | Reported against `cloudflare/ai`: with `base_url https://api.exa.ai` and slug `exa-provider`, `POST …/custom-exa-provider/search` arrived at `/v1/search`. | Until it is fixed, set `base_url` so the prepended `/v1` lands on the real path, and verify against the upstream's access log. |
| A dynamic route to a Workers AI model returns `400` on the universal endpoint but `200` on `/compat/chat/completions` | Reported against `ai-gateway-provider`: same model, prompt and OpenAI-shaped body, two outcomes; reproduced with a raw universal-endpoint request, so it is not the SDK serialiser. | Send dynamic Workers AI routes to `/compat/chat/completions`, or move to the REST API host. |
| Anthropic requests fail at random and succeed on retry | The request egressed from a PoP in a region Anthropic blocks. | Use the `api.cloudflare.com` REST host, or Unified Billing, rather than the anycast gateway host. |
| `429` | Your gateway rate limit, your spend limit, or the 200-per-60s Unified Billing ceiling. | Raise the limit, wait out the window, or split traffic across gateways. |
| Zero cache hits | `cache_ttl` is `0` on a new gateway, or the body carries a value that changes per request. | Set a TTL, and strip or relocate ids and timestamps. |
| No rows in **Logs** | `collect_logs` off, `cf-aig-collect-log: false`, storage limit reached with `DELETE_OLDEST` off, or a `@cf/` model called on the REST API without `cf-aig-gateway-id`. | Check the gateway settings, then the header. |

[[embed:source:s20]]
[[embed:source:s21]]

## Setup ends where billing and model choice begin

Credits, the 5% fee and what Unified Billing does and does not cover: [Cloudflare Unified Billing](/a/cloudflare-unified-billing). Which `@cf/` models are worth pointing a coding agent at and what they cost: [Workers AI coding models](/a/workers-ai-coding-models). Wiring a coding agent to a gateway end to end, including non-Anthropic models behind an Anthropic-shaped client: [Claude Code on Kimi, GLM or Grok through your own Cloudflare account](/a/claude-code-on-cloudflare-ai-gateway).


## Sources

1. Cloudflare AI Gateway — Get started — https://developers.cloudflare.com/ai-gateway/get-started/
2. Cloudflare AI Gateway — Authentication — https://developers.cloudflare.com/ai-gateway/configuration/authentication/
3. Cloudflare AI Gateway — REST API — https://developers.cloudflare.com/ai-gateway/usage/rest-api/
4. Cloudflare AI Gateway — Header glossary — https://developers.cloudflare.com/ai-gateway/glossary/
5. Cloudflare AI Gateway — Caching — https://developers.cloudflare.com/ai-gateway/features/caching/
6. Cloudflare AI Gateway — Rate limiting — https://developers.cloudflare.com/ai-gateway/features/rate-limiting/
7. Cloudflare AI Gateway — Logging — https://developers.cloudflare.com/ai-gateway/observability/logging/
8. Cloudflare AI Gateway — Costs — https://developers.cloudflare.com/ai-gateway/observability/costs/
9. Comment on Cloudflare's AI Platform — https://news.ycombinator.com/item?id=47806253
10. Authenticated AI Gateway support in Moltworker — https://github.com/cloudflare/moltworker/issues/74
11. Comment on AI Gateway cache hit rate — https://news.ycombinator.com/item?id=47301851
12. Comment on AI Gateway geographic egress — https://news.ycombinator.com/item?id=42862073
13. Cloudflare AI Gateway authentication support in cc-switch — https://github.com/farion1231/cc-switch/issues/2311
14. Cloudflare GraphQL Analytics API — https://developers.cloudflare.com/analytics/graphql-api/
15. Gateway latency A/B probe — https://miscsubjects.com/a/cloudflare-ai-gateway-setup
16. Live gateway configuration and 30-day account measurement — https://developers.cloudflare.com/api/resources/ai_gateway/methods/create/
17. Gateway-host versus REST-host authentication probe — https://developers.cloudflare.com/ai-gateway/configuration/authentication/
18. Five-request exact-cache probe — https://developers.cloudflare.com/ai-gateway/features/caching/
19. Comment on AI Gateway setup — https://news.ycombinator.com/item?id=46100225
20. Custom provider endpoint prepends /v1 — https://github.com/cloudflare/ai/issues/476
21. Dynamic Workers AI route fails on universal endpoint — https://github.com/cloudflare/ai/issues/617

