AWS Lambda vs Cloudflare Workers: Architecture, Cost, and When to Migrate
A deep technical comparison of AWS Lambda and Cloudflare Workers covering execution models, cold start characteristics, real-world pricing at scale, ecosystem differences, and concrete migration patterns with TypeScript code and a decision framework.
Most teams reach for AWS Lambda because it is the obvious default for serverless. It has been around since 2014, the ecosystem is mature, and IAM gives you fine-grained control over everything. Cloudflare Workers is younger, has a different execution model, and is frequently misunderstood as “just a CDN feature.” These are genuinely different platforms with different tradeoffs, and the choice matters more at scale than it does in a weekend project.
This article covers the execution model difference in enough depth that you can reason about it, walks through pricing with real numbers, shows the same API endpoint implemented on both platforms, and gives you a concrete decision framework for when Workers is worth migrating to.
One thing up front: this is not a comparison of which platform is better. They solve overlapping but distinct problems. The goal is to help you make the choice deliberately rather than by inertia.
Execution Model: Containers vs V8 Isolates
Lambda runs your code in a container. When a request arrives and no warm container exists, AWS provisions one, initializes the Node.js runtime (or whichever runtime you chose), runs your initialization code, then handles the request. That initialization path is the cold start. On subsequent requests, if the same container is still warm, the initialization is skipped.
The container lifecycle creates a few guarantees you can rely on: the global scope persists between requests on the same container, so module-level caches, database connection pools, and initialized SDK clients are reused. It also creates the limitation: you have one container per concurrent request. If your function handles 1000 concurrent requests, AWS spins up 1000 containers. Each has its own memory footprint, its own connection pool, its own cold start potential.
Cloudflare Workers uses V8 isolates. An isolate is a lightweight JavaScript execution context, similar to what your browser uses to sandbox tabs. Isolates share the V8 engine process but cannot access each other’s memory. The startup cost is measured in microseconds, not seconds, because there is no OS-level container to provision. The V8 engine is already running.
The tradeoff is that isolates have strict resource limits. Workers caps CPU time at 30ms on the free tier and 30 seconds on paid plans. More critically, isolates do not have access to Node.js APIs. The runtime is a subset of the Web Platform APIs: fetch, Request, Response, URL, TextEncoder, crypto, ReadableStream. If your Lambda function uses fs, net, child_process, or any native Node.js module, that code does not run in Workers without rewriting.
The memory model also differs. Workers does not guarantee global scope persistence between requests. Variables you set at module scope may or may not survive to the next request on the same isolate. Cloudflare does keep isolates warm in practice, but you cannot rely on in-memory caches across requests the way you can with Lambda’s container model. If you need shared state, it has to live in KV, Durable Objects, or an external data store.
Cold Starts: Real Numbers
Lambda cold starts vary by runtime and memory configuration:
- Node.js 20, 128 MB: 200-800ms cold start
- Node.js 20, 1024 MB: 150-400ms cold start
- Python 3.12, 128 MB: 100-300ms cold start
- Java 21 (with SnapStart): 1-3 seconds without SnapStart, under 100ms with
These numbers represent the initialization phase before your handler code runs. For a function with significant initialization (loading a large SDK, establishing a DB connection), add another 100-500ms on top.
Workers cold starts are typically under 5ms. The Cloudflare docs advertise sub-millisecond isolate initialization. In practice, from a measurement standpoint, the network latency from the PoP to your client dominates; the compute startup is not the bottleneck.
The practical implication: if you have a Lambda function serving latency-sensitive synchronous user-facing requests and you are seeing p99 spikes that correlate with traffic bursts, cold starts are likely contributing. Workers eliminates this category of problem entirely.
Lambda provisioned concurrency is the AWS answer to cold starts. You pay to keep containers warm at all times. We will cover the cost implications shortly.
The Same Endpoint on Both Platforms
Here is a realistic API endpoint: fetch a user by ID, validate the path parameter, look up from a data store, and return the result. This shows the structural differences between the two platforms.
Lambda (Node.js 20 with API Gateway v2):
import { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
// Module-level client reused across warm invocations
const dynamo = new DynamoDBClient({ region: process.env.AWS_REGION });
interface User {
id: string;
email: string;
name: string;
createdAt: string;
}
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
const userId = event.pathParameters?.userId;
if (!userId || !/^[0-9a-f-]{36}$/.test(userId)) {
return {
statusCode: 400,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'Invalid userId format' }),
};
}
try {
const result = await dynamo.send(
new GetItemCommand({
TableName: process.env.USERS_TABLE!,
Key: marshall({ id: userId }),
})
);
if (!result.Item) {
return {
statusCode: 404,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'User not found' }),
};
}
const user = unmarshall(result.Item) as User;
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
};
} catch (err) {
console.error('DynamoDB error:', err);
return {
statusCode: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'Internal server error' }),
};
}
};
Cloudflare Workers (same endpoint):
export interface Env {
USERS_KV: KVNamespace;
// Or use D1 for relational: USERS_DB: D1Database
}
interface User {
id: string;
email: string;
name: string;
createdAt: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Match /users/:userId
const match = url.pathname.match(/^\/users\/([0-9a-f-]{36})$/);
if (!match) {
return Response.json({ error: 'Invalid userId format' }, { status: 400 });
}
const userId = match[1];
const user = await env.USERS_KV.get<User>(userId, 'json');
if (!user) {
return Response.json({ error: 'User not found' }, { status: 404 });
}
return Response.json(user);
},
};
The Workers version is shorter, but not because it is doing less. The platform differences are visible in the structure:
- No SDK import: Workers uses the KV binding directly via
env.USERS_KV, which is injected by the runtime. There is no AWS SDK to initialize. - No module-level state for connection management: KV is accessed over an internal Cloudflare network path, not a TCP connection you manage.
- Error handling is cleaner:
Response.json()is a Web API, available natively. - Routing is manual: Workers does not have a path router by default. In production you would use a framework like Hono or itty-router to avoid writing regex matching by hand.
The Lambda version has one meaningful advantage in this example: DynamoDBClient is initialized once at module scope and reused across warm invocations. If this handler makes multiple DynamoDB calls per request, the connection reuse reduces latency. Workers does not give you that optimization for external connections.
Ecosystem: API Gateway and Storage
Lambda rarely runs alone. The AWS ecosystem around Lambda is deep, which is both an advantage and a source of complexity.
For HTTP APIs, API Gateway v2 (HTTP API) is the standard frontend. It handles routing, authentication via JWT authorizers, throttling, and CORS. You define routes in API Gateway that map to Lambda ARNs. The configuration lives in CloudFormation, CDK, or SAM templates. For event-driven work, Lambda connects to SQS, SNS, EventBridge, Kinesis, and DynamoDB Streams natively, with pollers managed by the Lambda service.
Workers routes are defined in wrangler.toml or the Cloudflare dashboard. Pattern-based routing maps URL patterns to Workers. There is no equivalent to the event-source integrations: Workers is HTTP-only by default (Cron Triggers exist for scheduled work, and Queue consumers handle asynchronous processing via Cloudflare Queues, but the integration surface is smaller).
Storage primitives:
| Need | Lambda Ecosystem | Workers Ecosystem |
|---|---|---|
| Key-value cache | ElastiCache (Redis) | KV (eventually consistent) |
| Relational DB | RDS, Aurora | D1 (SQLite, regional) |
| Object storage | S3 | R2 (S3-compatible) |
| Coordination/locks | DynamoDB conditional writes, SQS FIFO | Durable Objects |
| Message queues | SQS, SNS, EventBridge | Cloudflare Queues |
| Secrets | SSM Parameter Store, Secrets Manager | Workers Secrets (env vars) |
The Lambda ecosystem has a storage option for every access pattern. Workers’ storage is narrower: KV is not suitable for high-write workloads (it is eventually consistent with a write propagation delay), D1 is a single-region SQLite file (fine for read-heavy workloads, not for high-concurrency writes), and R2 is solid but lacks S3’s event notifications and lifecycle rules.
Durable Objects are the Workers answer to coordination problems that would normally require a mutex or a strongly-consistent data store. Each Durable Object instance has a single-threaded execution model and colocated storage, which makes it useful for building real-time features (presence, collaborative editing, rate limiters). There is no Lambda equivalent without reaching for external services.
Developer Experience: SAM/CDK vs Wrangler
Lambda’s local development story has improved but remains complex. SAM CLI provides sam local invoke and sam local start-api, which run Lambda functions in Docker locally. CDK lets you define your entire stack in TypeScript, including Lambda function code and the API Gateway, DynamoDB tables, and IAM roles that surround it. The CDK approach is powerful and produces reproducible infrastructure, but the feedback loop is slow: cdk deploy takes 2-5 minutes for even small stacks.
Wrangler is the Workers CLI and it is genuinely fast. wrangler dev starts a local development server that closely emulates the Workers runtime using Miniflare. Hot reload is near-instant. The KV, D1, and R2 bindings are emulated locally with no cloud round-trips. For most Workers development, you can work entirely locally for hours and only push to production with wrangler deploy.
The gap closes when your Lambda function needs to integrate with AWS services that SAM cannot emulate locally, such as EventBridge or Step Functions. At that point you are either mocking the AWS SDK or running against a dev account, which introduces environment drift.
# wrangler.toml - the entire deployment config for a Workers service
name = "users-api"
main = "src/index.ts"
compatibility_date = "2025-01-01"
[[kv_namespaces]]
binding = "USERS_KV"
id = "abc123"
preview_id = "def456"
[[d1_databases]]
binding = "USERS_DB"
database_name = "users"
database_id = "ghi789"
[vars]
ENVIRONMENT = "production"
Compare this with a minimal SAM template for the same Lambda function, which requires a separate template.yaml defining the function, its IAM role, the API Gateway, and the environment variables. A realistic production Lambda setup involves 200-400 lines of CloudFormation YAML before you have covered logging, tracing, and alarms.
Pricing at Real-World Scale
Pricing comparisons are frequently misleading because they cherry-pick favorable scenarios. Here are calculations at three scales using actual published pricing (us-east-1 / Workers standard plan, as of early 2026).
Lambda pricing components:
- Requests: $0.20 per million
- Compute: $0.0000166667 per GB-second (128 MB function for 100ms = 0.0000020833 per invocation)
Workers pricing components:
- $5/month for 10 million requests included
- $0.30 per additional million requests
- CPU time: $0.02 per million CPU-milliseconds (above free tier)
Scenario A: 1 million requests/month, 128 MB Lambda, 100ms average duration
Lambda:
- Requests: 1M × $0.0000002 = $0.20
- Compute: 1M × $0.0000020833 = $2.08
- API Gateway: 1M × $0.000001 = $1.00
- Total: ~$3.28/month
Workers (Standard plan, $5/month base):
- 1M requests included in base
- CPU at ~2ms per request: 2,000 CPU-ms total, well within free CPU tier
- Total: $5.00/month (base plan)
At low volume, Lambda wins on raw cost.
Scenario B: 50 million requests/month, 128 MB Lambda, 80ms average duration
Lambda:
- Requests: 50M × $0.0000002 = $10.00
- Compute: 50M × $0.0000016666 = $83.33
- API Gateway: 50M × $0.000001 = $50.00
- Total: ~$143.33/month
Workers:
- First 10M: $5.00 base
- Additional 40M × $0.30/M = $12.00
- CPU at ~2ms: 100,000 CPU-ms, still within free CPU tier for standard
- Total: ~$17.00/month
Workers is roughly 8x cheaper at this scale for simple, short-running handlers.
Scenario C: 50 million requests/month with Lambda Provisioned Concurrency (targeting p99 < 100ms)
Add provisioned concurrency to keep 10 containers warm:
- Provisioned: 10 × 0.128 GB × 730 hours × $0.000004646 per GB-hour = $43.50/month
- On top of Scenario B: ~$186.83/month
Workers does not have a provisioned concurrency concept because it does not have cold starts. You cannot spend money to fix a problem that does not exist.
The cost picture shifts if your Lambda functions run for seconds rather than milliseconds. Workers’ 30-second CPU limit and per-CPU-millisecond pricing make it expensive for compute-heavy workloads. A Lambda function doing 10 seconds of CPU work per request is straightforward. A Workers function doing the same is at the edge of the CPU limit and will accumulate significant CPU-ms charges.
Honest Limitations of Workers
Workers is not a drop-in replacement for Lambda. The limitations are real and will block certain migrations:
CPU time cap. Workers enforces a 30-second CPU time limit on the paid plan (Unbound workers). This is wall-clock CPU usage, not request duration (a request can wait on I/O indefinitely). For any compute-heavy workload, video transcoding, PDF generation, large dataset processing, this limit matters. Lambda functions can run for up to 15 minutes.
No VPC access. Workers cannot be placed inside an AWS VPC. If your existing architecture has a Lambda function connecting to RDS or ElastiCache in a private subnet, you cannot replicate that pattern with Workers. Hyperdrive exists to provide a connection pooler for Postgres, but it is a public endpoint, not a private VPC integration. This is the most common migration blocker in practice.
Limited runtime APIs. Workers runs on V8, not Node.js. Many npm packages work in Workers because they compile to plain JavaScript. But packages that use Node.js built-ins (fs, net, tls, crypto from Node, child_process) will fail. The nodejs_compat compatibility flag imports polyfills for some Node.js APIs, but it is not complete. Check your dependency tree carefully before committing to a migration.
No native streaming compute. Lambda integrates with Kinesis and DynamoDB Streams as event sources. Workers has no equivalent. If your Lambda functions process streaming data, that pattern stays on Lambda or requires an architectural change.
Single-region data. D1 is a regional SQLite database. Strong consistency is per-region, and writes go to a single primary. If you need globally distributed write consistency, you need Durable Objects or an external database accessed over the network.
Observability is immature. Lambda integrates natively with CloudWatch Logs, X-Ray tracing, and the broader AWS observability ecosystem. Workers logging goes to Cloudflare’s Logpush or Workers Tail, which requires more setup to get to the same level of structured log routing, alerting, and trace correlation.
Tradeoffs Comparison
| Dimension | AWS Lambda | Cloudflare Workers |
|---|---|---|
| Cold start | 100ms-3s (without provisioned concurrency) | Under 5ms |
| Max execution time | 15 minutes | 30 seconds CPU (Unbound plan) |
| Memory | Up to 10 GB | Up to 128 MB |
| Runtime | Node, Python, Java, Go, .NET, Ruby, custom | V8 (JS/TS/WASM) |
| VPC support | Yes (native) | No |
| Node.js built-ins | Full | Partial (with nodejs_compat) |
| Cost at 50M req/month | ~$143/month | ~$17/month |
| Ecosystem breadth | Extremely broad | Narrower but growing |
| Local dev experience | Slow (SAM/Docker) | Fast (Wrangler + Miniflare) |
| Event sources | SQS, SNS, Kinesis, DynamoDB, S3, many more | HTTP, Cron, Queues |
| Global distribution | Regional by default (Lambda@Edge for global) | Always global (300+ PoPs) |
| Observability | Mature (CloudWatch, X-Ray) | Basic (Logpush, Tail) |
Migration Patterns
If you have decided Workers is the right move for a given service, here is how to approach the migration without a big-bang rewrite.
Pattern 1: Parallel deployment with traffic shifting
Deploy the Workers version of the endpoint alongside the Lambda version. Use Cloudflare’s fetch inside a Worker to proxy traffic to the Lambda endpoint while you validate behavior:
// Proxy worker - runs in Workers, calls existing Lambda via API Gateway
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Forward to Lambda endpoint for comparison
const lambdaResponse = await fetch(
`${env.LAMBDA_API_GATEWAY_URL}${new URL(request.url).pathname}`,
{
method: request.method,
headers: request.headers,
body: request.body,
}
);
// Log both responses for validation
const workerResponse = await handleRequest(request, env);
// Compare response bodies during migration
if (env.SHADOW_MODE === 'true') {
logComparison(lambdaResponse, workerResponse);
return lambdaResponse; // Return Lambda response during shadow phase
}
return workerResponse;
},
};
This lets you run both implementations against production traffic without users seeing the new behavior until you are confident.
Pattern 2: Extract stateless handlers first
Not all Lambda functions are equally hard to migrate. Start with functions that:
- Do not connect to resources inside a VPC
- Have no Node.js built-in dependencies beyond
cryptoandurl - Run under 30 seconds of CPU time per request
- Are already structured around
Request/Responsesemantics
Functions that aggregate, transform, or proxy data are usually safe to migrate first. Functions that connect to RDS, use child_process, or run long CPU tasks stay on Lambda.
Pattern 3: Replace DynamoDB with KV or D1
If your Lambda function uses DynamoDB for key-value lookups and the access pattern fits (read-heavy, eventual consistency acceptable, key sizes under 25 MB), KV is a straightforward replacement:
// Lambda: DynamoDB GetItem
const result = await dynamo.send(new GetItemCommand({
TableName: 'sessions',
Key: marshall({ sessionId }),
}));
const session = result.Item ? unmarshall(result.Item) : null;
// Workers: KV get
const session = await env.SESSIONS_KV.get<Session>(sessionId, 'json');
The interface is simpler, but the consistency model is different. KV is eventually consistent with a propagation delay of up to 60 seconds. For session data that is written once and then read many times, this is acceptable. For data that requires read-after-write consistency immediately after creation, it is not.
Decision Framework
Use this to make the choice for a given service, not your whole platform at once:
Stay on Lambda if:
- The function connects to resources in a VPC (RDS, ElastiCache, internal services)
- The function uses native Node.js modules that have no Workers equivalent
- The function runs for more than a few seconds of CPU time per request
- The function consumes AWS event sources (Kinesis, SQS FIFO, DynamoDB Streams)
- You need more than 128 MB of memory
Consider Workers if:
- The function serves synchronous, latency-sensitive user-facing HTTP requests
- Cold start spikes are appearing in your p99 latency
- Your AWS bill for API Gateway and Lambda is growing faster than your user count
- The function is stateless or uses data stores that can be replaced with KV, D1, or an external API
- You want a simpler deployment model without CloudFormation
Evaluate carefully if:
- You are migrating a function with significant npm dependencies (audit for Node.js built-ins)
- The function currently uses DynamoDB with strong read consistency or transactions
- Your observability pipeline is tightly coupled to CloudWatch and X-Ray
The most common mistake is treating this as an all-or-nothing architecture decision. Lambda and Workers can coexist. A Cloudflare Worker can call a Lambda function via its API Gateway URL. A Lambda function can call a Workers endpoint. The practical path for most teams is to move the latency-sensitive, high-volume, stateless HTTP handlers to Workers and keep the compute-heavy, VPC-connected, event-driven work on Lambda.
Production Considerations
A few things that only become visible after you ship:
Request body size. Workers caps request body size at 128 MB. Lambda allows up to 6 MB through API Gateway synchronously (10 GB via S3 for async patterns). If your API accepts large file uploads, this is a hard constraint.
Bundling. Workers requires your code to be bundled into a single file (or a small number of chunks with module workers). Wrangler handles this with esbuild by default, but complex dependency trees can produce bundles that exceed the 1 MB compressed Workers script limit or the 10 MB uncompressed limit. Audit your bundle size early: wrangler deploy --dry-run --outdir dist will show you the compiled size before deployment.
Debugging production issues. Lambda gives you full stack traces in CloudWatch with correlation IDs. Workers gives you console.log output via wrangler tail, which streams logs in real time but does not store them unless you configure Logpush to an external sink. If you are used to querying CloudWatch Logs Insights across a time range, the Workers observability experience will feel like a step backward until you have Logpush routing to a log aggregation service.
Rate limiting and DDoS. API Gateway has built-in throttling per stage and per route. Workers has rate limiting via the Rate Limiting product (additional cost) or you can implement it yourself with KV. Neither approach is automatic; plan for it explicitly.
Closing Thoughts
The decision between Lambda and Workers is ultimately a question of what your service actually does. Workers wins on cold start latency, global distribution, pricing at high request volume, and developer experience for pure HTTP handlers. Lambda wins on runtime flexibility, ecosystem depth, VPC integration, execution time limits, and observability maturity.
Both platforms are genuinely useful. The teams that get the most out of Workers are the ones who moved specific services to it deliberately, having audited their dependencies and understood the storage tradeoffs, rather than the ones who tried to migrate everything at once. Start with your highest-volume, lowest-complexity Lambda functions. The operational properties of each platform will become clear after you have one service running in production on each.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.