Pages Functions vs a Cluster of Microservices
About Author
A formerly masked, now weaponized ASD-1 nihilist with an well-honed hatred for bullshit, who architects software systems just enough to fund a life spent exposing nepo-babies, working on taxing billionaires into extinction, and teaching everyone with a paycheck that their soul isn't a subscription.
I know exactly what I would have built five years ago, because I built it several times and got paid well to do so. An API of this shape meant three or four Node/Go services behind an ingress, each with its own repo or module, its own Dockerfile, its own deployment, talking over HTTP or gRPC, with a shared Postgres and Redis. That wasn’t a design decision so much as a reflex—the architecture your hands produce when nobody’s watching. It took actually building the alternative to notice how much of that reflex was habit wearing rigor’s badge.
This is part 3 of 10 in the RORBT field notes—dispatches from building a governance platform on Cloudflare. The whole series lives on the RORBT case study.
Pages Functions collapse the whole arrangement into a directory:
functions/
├── api/
│ ├── _middleware.ts → runs for every /api/*
│ ├── _lib/ → shared, never routed (underscore prefix)
│ │ ├── governance-store.ts
│ │ ├── orbit-store.ts
│ │ └── …
│ ├── account.ts → /api/account
│ ├── newsletter.ts → /api/newsletter
│ └── orbit/
│ └── [orbitId]/
│ ├── proposals.ts → /api/orbit/:orbitId/proposals
│ └── proposals/
│ ├── [proposalId].ts → /api/orbit/:orbitId/proposals/:proposalId
│ └── [proposalId]/
│ └── vote.ts → …/:proposalId/vote
└── dashboard/
└── [[path]].ts → catch-all
Routing is the filesystem. Method dispatch is the export name—onRequestGet, onRequestPost, onRequestDelete, onRequestOptions. Middleware is a file called _middleware.ts that receives next(). That’s the entire model. You can hold it in your head on the first read.
No service discovery. No ingress rules. No inter-service auth. No mesh. No Dockerfile. The routing table that used to be 300 lines of Ingress YAML is now ls -R.
What genuinely improves
Shared code is an import, not a network call. In the microservice version, “the governance service needs the orbit roster” is an HTTP call with a timeout, a retry policy, a circuit breaker, a serialization boundary, and its own failure mode. Here it’s:
import { loadOrbit } from './_lib/orbit-store';
An enormous amount of accidental complexity—the kind that generates incidents—simply doesn’t exist. My distributed-systems instincts kept looking for it, the way your foot keeps reaching for a stair that isn’t there. It took weeks to stop bracing for failure modes I no longer had.
Most of what we call distributed-systems expertise is the skill of surviving boundaries we drew ourselves.
Latency composition disappears. No N+1 across service boundaries, because there are no service boundaries. A request that touches governance, the roster, and the transparency log is one isolate doing three storage reads.
Deploy is atomic across the whole API. No version skew between services mid-rollout. In k8s I spent real effort on backward-compatible schema changes purely because service A and service B would be different versions for ninety seconds. Real effort, spent well, on a problem I had chosen to have.
What you lose, and it’s real
I want to be honest about the other column, because the trade is genuine and pretending otherwise is how you end up writing an angry retraction in a year.
You cannot exec into anything. No kubectl exec, no debug sidecar, no attaching a profiler to a live pod. When something misbehaves in production your only instrument is what you logged. This single constraint is why I ended up building structured logging with request correlation (post 10) far earlier than I otherwise would have—on k8s I’d have shelled in and poked around instead. The platform forced a discipline I’d been happily deferring for a decade.
No long-running processes. No background worker consuming a queue, no in-memory cache warmed at startup, no connection pool. Every request starts cold conceptually, even though the isolate is warm.
No sidecars. No Envoy for mTLS, no fluentd for log shipping, no OpenTelemetry collector as a local daemon. Anything cross-cutting has to be library code in your bundle.
CPU limits are tight and real. Milliseconds of CPU per request, not seconds. Fine for I/O-bound work, a hard wall for anything computational.
The bug: unmatched routes return 200 with HTML
This one is worth the whole post, and I’d like it on the record that it cost me an afternoon I will not get back.
Pages serves static assets and Functions from the same origin. When no Function matches a request, it falls through to the static asset handler. For a path under /api/ with no matching file, that handler serves the SPA shell—with a 200.
$ curl -o /dev/null -w "%{http_code} %{content_type}\n" \
https://…/api/definitely-not-a-route
200 text/html; charset=utf-8
$ curl -o /dev/null -w "%{http_code}\n" https://…/api/account # only exports DELETE
200
Every client doing the standard thing is now broken:
const res = await fetch('/api/acount'); // typo
if (!res.ok) throw new Error('failed'); // ← does not fire. res.ok is TRUE.
const data = await res.json(); // ← explodes here, with a parse error
// that tells you nothing useful
You get a JSON parse error pointing at <!DOCTYPE html>, several layers away from the actual mistake. On k8s this is a 404 from the ingress and you’re done in ten seconds. Here, the platform looked me in the eye and told me everything was fine.
A 404 is bad news. A 200 with the wrong body is bad news wearing good news’s clothes, and it will walk right past every check you wrote.
The fix goes in middleware, and the detection is a heuristic that happens to be sound here:
/**
* Nothing under /api serves HTML and there are no static files in that
* namespace, so an HTML body can only mean Pages found no function matching
* this path *and* method and fell through to the site shell.
*
* Redirects are excluded—better-auth issues them for email verification and
* OAuth callbacks, and those are real responses.
*/
function isAssetFallback(res: Response): boolean {
if (res.status >= 300 && res.status < 400) return false;
return (res.headers.get('content-type') ?? '').includes('text/html');
}
async function settle(request: Request, next: () => Promise<Response>) {
const res = await next();
return isAssetFallback(res) ? noSuchEndpoint(request) : res;
}
Returning:
{"error":"No API endpoint answers GET /api/account"}
Naming the method matters, because this covers two distinct failures—“no such path” and “path exists but doesn’t export that method”. Middleware can’t see the function map to distinguish them, but a caller reading that message gets to the answer either way.
The redirect exclusion is the part I nearly got wrong, and I’ll say so plainly because the near-miss is the lesson. A naive content-type check would have broken every OAuth callback and email-verification link in the app—I would have shipped a fix for a confusing bug and bought a catastrophic one with the change.
flowchart TD
A[Request /api/*] --> B{Function matches<br/>path AND method?}
B -->|yes| C[Handler runs] --> D[JSON response]
B -->|no| E[Static asset handler]
E --> F[SPA shell, 200, text/html]
F --> G{middleware:<br/>HTML and not 3xx?}
G -->|yes| H[404 JSON<br/>+ x-request-id]
G -->|no| D
style F fill:#7f1d1d,color:#fff
style H fill:#14532d,color:#fff
Middleware is real, and it’s the right place for cross-cutting concerns
functions/api/_middleware.ts runs for every /api/* request and wraps next(). Mine does four things that would each have been a sidecar or a mesh policy:
export const onRequest: PagesFunction<Env> = async (ctx) => {
configureLogging(ctx.env); // 1. log level from env
return await withRequestContext( // 2. request-scoped trace ctx
{ requestId: requestIdFor(ctx.request), method: …, path: … },
() => handle(ctx),
);
};
const handle: PagesFunction<Env> = async ({ request, env, next }) => {
const tier = classify(request.method, pathname);
if (tier) { /* 3. per-IP rate limiting, fails open */ }
return settle(request, next); // 4. asset-fallback → JSON 404
};
Rate limiting deserves a note. On k8s this was an Envoy filter or an nginx limit_req zone—infrastructure, not code. Here it’s a D1-backed fixed-window counter, because KV and Durable Objects both would have required config changes that were off-limits at the time:
// Coarse per-IP limiter. Cloudflare's network DDoS protection stops volumetric
// floods; this stops *application-level* abuse—a single authenticated member
// hammering expensive endpoints—which looks like legitimate traffic at the edge.
// Fails OPEN: a limiter-store hiccup must never take the whole API down.
That fail-open decision is the same one you’d make in Envoy. The difference is that here it’s four lines you own, and you can read them. I’ve spent enough of my life spelunking through mesh configuration to know what that’s worth.
When I’d still reach for microservices
Strong opinions, loosely held—so here is where the reflex was right all along.
Genuinely heterogeneous runtimes. If one component wants Go for CPU-bound work and another wants Python for ML, you’re not doing that in a Worker.
Long-running or stateful work. Queue consumers, batch jobs, WebSocket servers holding thousands of connections with in-memory state. (Durable Objects cover a surprising amount of the last one—post 05.)
Independent scaling or independent deploy cadence. Fifteen teams shipping on their own schedules need boundaries. Pages Functions give you one deployable.
Existing investment. The migration cost for a working cluster is not repaid by elegance.
None of those describe RORBT, and if I’m honest, none of them described most of the systems I sliced into services over the years either. For a small team building one coherent product, the monolith-in-a-directory is the better answer, and it isn’t close. The cluster was never the goal. It was the toll I’d stopped noticing I was paying.
Previous: Wrangler After Kubernetes · Next: Storage Without Transactions
Related
Durable Objects Are the Actor Model Wearing a Database Costume
Cloudflare Durable Objects give you mutual exclusion by construction—one single-threaded actor per orbit id—plus per-entity scheduling via setAlarm. What they don't give you is cross-store atomicity: the saga machinery stays, and serialization and transactions turn out to solve orthogonal problems.
Storage Without Transactions
D1 gives you an atomic batch() that forbids data dependencies; R2 gives you etag compare-and-set and nothing else. How a guarded UPDATE shipped a silent permanent-data-loss bug, the executed_at column that fixed it, and a 17-write orbit-split saga journaled with a partial unique index instead of a distributed lock.
Cloudflare vs AWS: The Mental Model That Doesn't Transfer
AWS-to-Cloudflare translation table, honestly graded: Lambda→Workers and S3→R2 mostly hold, D1 is not Aurora, and IAM's replacement—bindings as injected capabilities—is the one concept that doesn't map. Plus the cross-deployment Durable Object binding that actually bit me.