---
slug: cloudflare-os-functions
title: "Pages Functions compiles 224 route files into one 1.35 MB Worker"
kind: essay
status: published
hash: 9a584d4017a4250fd7bd739adff8ade774ddb30537d9edde4252118c005ce393
created: 2026-07-26T03:59:28.710Z
updated: 2026-10-08T11:33:03.881Z
summary: "A complete Pages Functions guide: file routing, middleware, _routes.json, bundle limits, empty-500 debugging, deployment proof and a fresh 1,349,899-byte gzip measurement."
tags: ["cloudflare","architecture","workers","cloudflare-os","pages-functions","routing","middleware","wrangler","deployment"]
above: []
below: []
adjacent: []
nested: []
---
# Pages Functions compiles 224 route files into one 1.35 MB Worker

*A complete Pages Functions guide: file routing, middleware, _routes.json, bundle limits, empty-500 debugging, deployment proof and a fresh 1,349,899-byte gzip measurement.*

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.

## Facts

```json
{
  "converted_from": "markdown",
  "original_updated": "2026-07-26T05:59:19.635Z",
  "register": "essay",
  "category": null
}
```

## Sources

1. [Pages Functions routing](https://developers.cloudflare.com/pages/functions/routing/) — “Functions utilize file-based routing. Your `/functions` directory structure determines the designated routes that your Functions will run on.” (sha256 ea50cf102eb32192)
2. [Pages Functions API reference](https://developers.cloudflare.com/pages/functions/api-reference/) — “The `onRequest` method will be called unless a more specific `onRequestVerb` method is exported.” (sha256 3da4a5f417a84022)
3. [Pages Functions middleware](https://developers.cloudflare.com/pages/functions/middleware/) — “Middleware is reusable logic that can be run before your `onRequest` function.” (sha256 429968d741c6d116)
4. [Pages _routes.json routing controls](https://developers.cloudflare.com/pages/functions/routing/) — “More specific routes (routes with fewer wildcards) take precedence over less specific routes.” (sha256 4660797eb516316b)
5. [Cloudflare Pages limits](https://developers.cloudflare.com/pages/platform/limits/) — “Cloudflare Pages has a limit of 100 projects per account.” (sha256 b3f6c04142775cf8)
6. [Cloudflare Workers limits](https://developers.cloudflare.com/workers/platform/limits/) — “Worker size” (sha256 cbb1411b666d1e59)
7. [Pages Functions debugging and logging](https://developers.cloudflare.com/pages/functions/debugging-and-logging/) — “Logs are available for every deployment of your Pages project.” (sha256 a3c5d14045a0aafa)
8. [Migrate from Pages to Workers](https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/) — “Unlike Pages, Workers has a distinctly broader set of features available to it” (sha256 64b33649124e20d2)
9. [Pages Direct Upload](https://developers.cloudflare.com/pages/get-started/direct-upload/) — “Direct Upload enables you to upload your prebuilt assets to Pages and deploy them to the Cloudflare global network.” (sha256 25c021c88648d256)
10. [Pages Functions local development](https://developers.cloudflare.com/pages/functions/local-development/) — “The main command for local development on Pages is `wrangler pages dev`.” (sha256 eff4053a583d8dbd)
11. [OpenNext Cloudflare bundle troubleshooting](https://opennext.js.org/cloudflare/troubleshooting) — “Only the latter (gzipped size) matters for these limits.” (sha256 cb6193273589791f)
12. [Cloudflare workers-sdk](https://github.com/cloudflare/workers-sdk) — “Home to Wrangler, the CLI for Cloudflare Workers®” (sha256 6cbc64755ec972c9)
13. [Independent WASM bundle-limit reproduction](https://github.com/nuxt-modules/og-image/issues/193) — “the compiled-wasm file is 2.4mb” (sha256 a88b7080f9114524)
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) — “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” (sha256 89ba313395692b0f)
15. [Deploying on Cloudflare Pages, script_too_large?](https://github.com/nuxt-modules/og-image/issues/193) — “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.” (sha256 4217076d87320c5a)
16. [🐛 BUG: pages publish very slow / crashing](https://github.com/cloudflare/workers-sdk/issues/1194) — “When I upload to pages with wrangler 2 it goes reaaallly slow and then crashes” (sha256 dd4ccbb21b140458)
17. [Cloudflare recommends migrating from Pages to Workers](https://news.ycombinator.com/item?id=44854848) — “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.” (sha256 5f378ef0f8103dfb)
18. [Magit manuals are available online again](https://news.ycombinator.com/item?id=45936226) — “Because Cloudflare pages has this doofy deploy limit hanging over your head. Even if you won't reasonably run into it, it's still weird. R2's free limits are significantly higher.” (sha256 1c027a6e38ea5ee0)
19. [Claude 3.7 Sonnet and Claude Code](https://news.ycombinator.com/item?id=43164386) — “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” (sha256 d5c6003fa6e2d4af)
20. [Journey to Optimize Cloudflare D1 Database Queries](https://news.ycombinator.com/item?id=43646198) — “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.” (sha256 6024bebc391ee3e9)
21. [Cloudflare recommends migrating from Pages to Workers](https://news.ycombinator.com/item?id=44855519) — “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.” (sha256 c8afa33dbf84f7c9)
22. [5 months ago I'd never coded anything. I now have full-stack analytics platform](https://news.ycombinator.com/item?id=47146087) — “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” (sha256 19328acbda39349c)
23. [Fresh first-party Functions tree inventory](https://miscsubjects.com/api/articles/cloudflare-os-functions) — “JavaScript modules under `functions/` | 391” (sha256 da7d4f6c1b88db79)
24. [Fresh first-party Pages Functions compilation](https://miscsubjects.com/api/articles/cloudflare-os-functions) — “Gzipped bundle | 1,350,472 bytes; 45.0% of the 3 MB free ceiling” (sha256 55e6f881a3efd66d)
25. [First-party cold and warm request timing](https://miscsubjects.com/api/articles/cloudflare-os-functions) — “Median time to first byte: 145 ms for the Function route on a warm connection, 68 ms for the excluded static route.” (sha256 6a384cf8e0ca49d4)
26. [First-party overlapping-route receipt](https://miscsubjects.com/api/articles/cloudflare-os-functions) — “The response proves nothing about which one ran” (sha256 31d87d09a9d38ace)
27. [First-party deploy-output receipt](https://miscsubjects.com/api/articles/cloudflare-os-functions) — “✨ Uploading Functions bundle” (sha256 e8f68d337b70040d)
28. [Fresh first-party _routes.json inventory](https://miscsubjects.com/api/articles/cloudflare-os-functions) — “`_routes.json` rules | 1 include + 15 exclude” (sha256 85ae459fd5ba8253)
