# Cloudflare OS: what this build has not installed

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

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

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

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

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

## What is already here

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

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

## What is empty

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

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

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

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

## The ten parts

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

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

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

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

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

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

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

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

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

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

## The two that fix existing failures

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

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

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

Those two are not enrichment. They are repairs.

## How to read the verdicts

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


## Sources

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


---

# 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

