# 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

