SHARKPARTY
K Kam avatar Kam

Cloudflare vs AWS: The Mental Model That Doesn't Transfer

RORBT cloudflare AWS engineering architecture

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.

AWSCloudflareHonest?
LambdaWorkersMostly—but no cold start, and a much tighter CPU budget
API Gateway + LambdaPages FunctionsYes, minus the 400 lines of routing config
S3R2Yes, plus S3-compatible API and no egress fees
Aurora Serverless / RDSD1No. SQLite semantics, no connection pool, no interactive transactions
DynamoDB single-item opsDurable Object storagePartly—DO gives you serialization DynamoDB doesn’t
Step FunctionsnothingYou write the state machine
EventBridge SchedulerCron Triggers / DO alarmsYes, with caveats
Secrets ManagerWorker secretsYes, minus rotation policies and versioning
IAM rolesBindingsThis 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.


Next: Wrangler After Kubernetes