Cloudflare Workers vs AWS Lambda at the Edge: Six Months of Production Reality

My team spent the better part of last summer arguing about which edge runtime to standardize on. Four engineers, one product manager who kept forwarding Hacker News threads, and a deadline that kept moving. We ended up running both Cloudflare Workers and AWS Lambda (including Lambda@Edge) in production at the same time — not because we planned to, but because two different features got built by two different people with two different opinions.

Six months later, I have opinions.

The Use Case That Made This More Than a Benchmark Exercise

We were building an API layer for an AI-powered image annotation tool. User uploads an image, we run lightweight ML inference at the edge to return bounding box data before the full model on our backend finishes processing. Low latency was the entire point — if the edge layer added more than 50ms we’d have been better off routing straight to our EC2 instances.

The edge inference shim went on Workers. The authenticated API routing — user sessions, JWT validation, rate limiting against our Redis cluster in us-east-1 — started as a Lambda Function URL setup and gradually moved toward Lambda@Edge as we got smarter (and then less smart, and then smarter again) about global routing.

So this isn’t synthetic benchmarks I ran on a Saturday. Real traffic: roughly 2.3M Workers requests and about 800k Lambda invocations per month at peak. A team that had to live with these decisions.

Cold Start Numbers After Six Months of Actual Traffic

Workers cold starts are effectively zero. I know everyone says this, but I was still surprised by how consistently true it holds. Cloudflare’s dashboard showed p99 cold start overhead at 2–5ms. The V8 isolate model is not a marketing trick — you’re not spinning up a container, you’re instantiating a JS context inside an already-running process. It really does work that way.

Lambda was messier. Our functions were Node.js 20, bundled with esbuild, around 3.2MB zipped. Cold starts in us-east-1 averaged 280ms, occasionally spiking to 500ms after a long quiet period. With Provisioned Concurrency on our three most latency-sensitive functions, p99 came down to ~18ms — but Provisioned Concurrency costs money (I’ll get to that).

Lambda@Edge specifically — and this is where I think a lot of comparisons gloss over — cold starts at edge PoPs are often worse than in a primary region. We saw 400–600ms cold starts at some locations, particularly in Southeast Asia and parts of South America where Cloudflare actually has strong infrastructure. The reason: Lambda@Edge functions run in a subset of around 13 AWS regions, not at every PoP. So “edge” in Lambda@Edge terms means “closer than a single origin region” but definitely not “at the network edge.”

One thing I noticed: Workers cold start behavior is globally uniform because every Cloudflare PoP runs the same isolate model. Lambda@Edge is not uniform — it depends entirely on whether a warm instance exists in the nearest serving region. I thought this would matter less in practice. It mattered more.

If cold start latency is what’s driving your architecture decision, Workers wins and it’s not close. You can reach acceptable Lambda performance with Provisioned Concurrency, but you’re paying to solve a problem Workers doesn’t have.

The Night Lambda@Edge’s 1MB Limit Wrecked Our Image Pipeline

I pushed an update to the annotation API on a Friday afternoon. I know. I know.

Lambda@Edge has a 1MB response body limit for origin-facing responses (40KB for viewer-facing). Our API was returning base64-encoded thumbnail crops alongside bounding box data. Some responses were hitting 1.2–1.4MB.

Lambda@Edge silently dropped the response body and returned a 502. Not a useful error. Just CloudFront returning a 502. I spent three hours digging through CloudWatch Logs before I found a note buried in the AWS docs about response size limits. It wasn’t surfaced in any error message or CloudFront distribution event — just: your request failed, good luck.

We refactored to return pre-signed S3 URLs for the thumbnail data instead of the raw bytes. Honestly a better architecture — smaller responses, client fetches from S3 directly, the annotation metadata comes back fast. But discovering a hard infrastructure limit because prod broke at 11pm is a terrible way to learn this.

Workers has limits too — 128MB memory, 50ms CPU time on the free plan, 30 seconds on paid — but they feel less surprising in practice. Wrangler will catch some of these before deploy. The documentation is upfront in a way that Lambda@Edge’s documentation wasn’t (at least when we hit this issue in mid-2025).

The other Lambda@Edge frustration: you can’t use environment variables the normal Lambda way. Configuration has to be baked into the function code or fetched from Parameter Store at runtime, which adds latency and a surprising amount of boilerplate. Workers handles this cleanly — wrangler.toml bindings, a typed env object passed into your handler. Much cleaner model.

Workers’ Runtime Isn’t Node.js, and That Cost Me a Full Week

Here’s the thing: Cloudflare Workers runs V8 with a web-standard API surface. Not Node.js. Most of the time this doesn’t bite you. But when you need a library that uses fs, path, child_process, or the Node.js crypto module (as opposed to Web Crypto) — or anything with native bindings — you hit a wall.

We tried to use jsonwebtoken for JWT validation in Workers. Works fine on Lambda. In Workers, it blew up because it calls into Node’s crypto internally. The fix was switching to jose, which uses the Web Crypto API and works great everywhere. The actual migration was maybe two hours. The debugging was four days, because I was certain the problem was something else — a bundling issue, a wrangler config problem. The error messages Workers gives you when you accidentally touch a Node built-in are not always intuitive.

// Worked on Lambda, broke on Workers without nodejs_compat:
import jwt from 'jsonwebtoken';
const decoded = jwt.verify(token, process.env.JWT_SECRET);

// What we switched to — works everywhere, cleaner key rotation story:
import { jwtVerify } from 'jose';
const secret = new TextEncoder().encode(env.JWT_SECRET);
const { payload } = await jwtVerify(token, secret);

Cloudflare has been expanding their Node.js compatibility layer — nodejs_compat in wrangler.toml handles a lot more than it did a year ago. Buffer, EventEmitter, stream — much of this works now. But if your existing Lambda code uses the broader Node ecosystem heavily, budget a real migration effort. Do not assume it’s a lift-and-shift.

Lambda just runs Node.js. That’s a genuine advantage if you’re inheriting code you didn’t write, or if your team’s muscle memory is Node. The breadth of npm packages that run without modification on Lambda vs. Workers is not comparable right now.

The Cost Math for a Team of Four, Six Months In

Real numbers:

Workers (paid plan): ~$12/month for 2.3M requests. Workers charges $0.30 per million beyond the 10M included in the $5/month base. Our CPU time stayed well within limits — the inference shim is lightweight, heavy computation happens on backend EC2. Billing was predictable.

Lambda + Lambda@Edge: This is where it got complicated. Lambda compute itself was cheap — maybe $4/month for our invocation count and average duration. Then add Lambda@Edge replication charges (your function gets copied across AWS regions, and you pay for that storage), CloudWatch Logs ingestion, and Provisioned Concurrency on our three latency-sensitive functions. All in: about $38/month.

Thirty-eight dollars is not a lot. But it was three times what I estimated from the Lambda pricing calculator alone, because the ancillary costs aren’t surfaced clearly. Workers pricing is more honest about what you’ll actually pay. I’ve started budgeting for AWS by taking the calculator number, adding 50%, and hoping for the best — which is not a compliment.

I’m not 100% sure this ratio holds at significantly higher scale. At 100M requests/month, Lambda’s per-invocation costs drop with compute savings plans, and the math might shift. If you’re operating at that scale you should model it carefully rather than trusting my numbers. My numbers are from a small team running moderate traffic.

Workers KV can add up fast if you’re doing heavy reads. We were caching inference results there and it added another $8–10/month at our read volume. Not a dealbreaker, just worth knowing upfront.

Where I’d Actually Put New Work Today

Look, I’ll give you the direct answer instead of a balanced take: Workers is where I’d start new projects.

The case for Workers: cold starts don’t exist at meaningful scale, the ecosystem (Workers, KV, R2, D1) is solid enough now that I don’t feel like I’m assembling something experimental, and the developer experience with Wrangler is genuinely good. I’d have said otherwise 18 months ago. Not anymore.

The case for staying on Lambda: if you’re already deep in AWS — IAM roles, VPC access, RDS connections, SQS queues — re-doing that in Workers is genuinely non-trivial. Same goes if your team’s operational muscle memory lives in CloudWatch and the AWS console. Those aren’t small things, and I’m not going to pretend the switching cost is nothing.

We’ve started migrating our Lambda@Edge functions specifically. The edge performance gap is real and the hard limits kept surprising us in bad ways. But I’m not telling teams deep in AWS to rip things out — the integration surface is wide.

My actual take: Workers for new projects. Lambda stays where it’s doing useful work in existing AWS infrastructure. The overlap — cases where either would be genuinely fine — is smaller than most comparison posts suggest.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top