Cloudflare vs AWS: The Mental Model That Doesn't Transfer
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 came to Cloudflare the way most people with a decade of AWS in their fingers do: carrying a translation dictionary. Lambda is Workers, S3 is R2, and so on down the menu—and the dangerous thing about that dictionary is that it’s mostly correct, which is exactly the property that lets it walk you confidently into the one place where it’s wrong. Here’s the version of the table I wish someone had handed me on day one, with an honesty column attached.
This is part 1 of 10 (so far?) in the RORBT field notes—dispatches from building a governance platform on Cloudflare. The whole series lives on the RORBT case study.
| AWS | Cloudflare | Honest? |
|---|---|---|
| Lambda | Workers | Mostly—but no cold start, and a much tighter CPU budget |
| API Gateway + Lambda | Pages Functions | Yes, minus the 400 lines of routing config |
| S3 | R2 | Yes, plus S3-compatible API and no egress fees |
| Aurora Serverless / RDS | D1 | No. SQLite semantics, no connection pool, no interactive transactions |
| DynamoDB single-item ops | Durable Object storage | Partly—DO gives you serialization DynamoDB doesn’t |
| Step Functions | nothing | You write the state machine |
| EventBridge Scheduler | Cron Triggers / DO alarms | Yes, with caveats |
| Secrets Manager | Worker secrets | Yes, minus rotation policies and versioning |
| IAM roles | Bindings | This is the one that doesn’t transfer |
That last row is the whole post.
Bindings are capabilities, not configuration
On AWS, access is ambient and negotiated at runtime. Your Lambda has an execution role. The role has a policy. The SDK reads credentials from the environment, hits STS, gets temporary creds, and talks to a global endpoint over the network. The bucket name is a string you got from an env var, and a string is a promise, and if the promise is broken you find out at runtime, in production, with an AccessDenied that could mean five different things.
Cloudflare inverts this so thoroughly that it took me an embarrassing amount of time to notice the inversion. A binding is an object injected into your handler:
export const onRequestGet: PagesFunction<Env> = async ({ env }) => {
const object = await env.ORBITS.get('orbits/ab/some-id.json');
// ^^^^^^^^^^ not a client you constructed.
// not a name you resolved.
// a live capability handed to you.
};
There is no credential. There is no endpoint. There is no SDK initialization, region, or retry policy to configure. env.ORBITS either exists because the platform granted it, or your code doesn’t run.
A binding is not configuration that points at a resource. It is the resource, already in your hand.
The consequences are larger than the ergonomics suggest, and I want to give all three of them, because the third one is the honest one.
Wrong bindings fail at deploy, not at 3am. wrangler deploy --dry-run enumerates every binding and resolves it. A typo’d bucket name is a build error. Compare with an IAM policy referencing a bucket that doesn’t exist yet—that’s a runtime 403 whenever the code path is first exercised, which could be weeks.
Least privilege is structural. A Worker can only touch what’s in its config. There is no equivalent of an over-broad s3:* policy quietly granting access to 47 buckets, because there is no policy language—there’s a list of specific objects. I found this genuinely reduces anxiety, in the way that removing a category of possible mistake always does.
But the blast radius of the config file goes way up. On AWS, IAM is centralized and audited separately from application code. Here, capability grants live in wrangler.toml next to your build settings, edited by whoever edits the app. That’s a real trade-off, and I don’t think it’s strictly better—it’s a different place to put the risk. Anyone selling you the capability model without mentioning this row is selling.
The thing that actually bit me: bindings don’t cross the deployment boundary
Durable Objects can only be defined in a Worker, not in a Pages project. So a Pages app that needs a DO must bind to one defined elsewhere:
# In the Pages project's wrangler.toml
[[durable_objects.bindings]]
name = "ORBIT_COORDINATOR"
class_name = "OrbitCoordinator"
script_name = "rorbt-orbit-room" # <- lives in a different deployable
This is the closest thing on the platform to a service mesh dependency, and it comes with the same ordering hazard I thought I’d left behind: deploy the Pages side first and the binding points at a class that doesn’t exist. There’s no health check to catch it. I know this because I did it, and the failure announced itself with all the clarity you’d expect from a capability that simply isn’t there.
I handled it the way you’d handle any optional dependency—degrade rather than fail:
export function hasCoordinator(env: Env): boolean {
return typeof env.ORBIT_COORDINATOR?.idFromName === 'function';
}
export async function settleOrbit(env: Env, orbit: OrbitRecord): Promise<void> {
if (hasCoordinator(env)) {
try {
await orbitCoordinator(env, orbit.id).settle(orbit.id);
return;
} catch (e) {
log.warn('coordinator settle failed; falling back to inline', { err: e });
}
}
// Do the work in-process instead.
await settleFoundingWindow(env, orbit);
await settleSplit(env, orbit);
}
Worth being precise about why this matters more than it looks: the test harness has no DO either, and neither does local dev. Without the fallback, adding the coordinator would have broken every environment until both deployables shipped together. Optionality wasn’t defensive programming—it was the only way to ship incrementally, and I only recognized that after I’d written it for the wrong reason.
What I miss from AWS
Step Functions. I now hand-roll state machines with a journal table (post 04). Step Functions would have given me retries, timeouts, visual execution history, and exactly-once-ish semantics for free. This is the single biggest capability gap I hit, and I hit it early and often.
Interactive transactions. BEGIN … COMMIT across multiple statements with application logic in between. D1 has batch()—atomic, but no data dependencies between statements. That constraint reshaped my entire data layer, which is a sentence I’d have found alarming before I did it and now merely find true.
IAM as a separate audit surface. Being able to answer “who can read this bucket” from outside the application repo.
Managed connection pooling. Not needed here—bindings have no connections—but the absence means D1’s limits are different, not gone.
What I don’t miss
The distinction between “my code” and “the network”. On AWS, every storage call is an HTTP request you configure, retry, and instrument. Here it’s a method call on an object. The gap between “local function” and “remote service” narrows to almost nothing, and a surprising amount of accidental complexity disappears with it—complexity I had stopped noticing because I’d been carrying it so long it read as weight-neutral.
Cold starts as an architectural constraint. V8 isolates start in single-digit milliseconds. I never once thought about provisioned concurrency, keep-warm pings, or bundling to reduce init time. That’s a whole category of work that simply doesn’t exist, and I did not hold a memorial.
Egress pricing shaping design. R2 has none. I stopped thinking about it.
The mental model that actually works
Stop thinking “my service calls other services.”
Start thinking “my function is handed the capabilities it needs, and the platform decides where it runs.”
The platform stopped feeling like a constrained Lambda and started feeling like a different computational model that happens to speak HTTP.
Every design decision downstream—no transactions, no cron without config, no sidecars, no long-running processes—follows from that one sentence. For the first few weeks I kept reaching for the AWS translation dictionary, and every time it half-worked it cost me more than an honest failure would have. Once I internalized the capability model instead, the dictionary went in a drawer, and the platform started making a kind of sense that no table of product-name analogies could have given me. The table is where you start. The last row is where you actually have to live.
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.
Pages Functions vs a Cluster of Microservices
Collapsing what would have been three or four services behind an ingress into a single Cloudflare Pages Functions directory—filesystem routing, middleware as code instead of sidecars, a D1-backed rate limiter, and the fallback bug where every unmatched /api/ route returns the SPA shell with a 200.