captured against cloudflare workflows GA · aws lambda durable functions · january 2026
When AWS announced Lambda Durable Functions, my first thought was: finally, AWS's answer to Cloudflare's Durable Objects, the main ingredient behind the Agents framework.
That was not the case. The real match is Cloudflare Workflows vs AWS Durable Functions. Durable Objects is a different product entirely (stateful actors, per instance storage), and the naming doesn't make life easier for anyone drawing architectures across AWS, Azure, and Cloudflare.
To get a real feel for the two, I built the same workflow on both platforms and turned it into a hands on breakdown. The demo: a ticket reservation with a fifteen minute hold. A customer reserves a seat, has fifteen minutes to complete payment, and if they don't the seat auto releases. Same pattern that runs behind every event ticketing site.
This is the writeup. Full code lives on GitHub: fawzy-tat/cloudflare-vs-aws-durable-workflows.
Fig. 01 · two products, one problem shape
What both solve
Before the syntax fight, the shared ground. Both products answer the same question: how do you write a multi step process that survives restarts, retries individual steps without redoing everything, waits for hours or days without burning compute, and keeps its state on disk somewhere the runtime can pick it back up?
The three primitives in Fig. 02 are all you need. Run some work and checkpoint the result. Sleep for a while. Wait for an external event. Both platforms ship the first two. Only Cloudflare ships a first class version of the third.
The syntax · same patterns, different words
Read Listing 01 by tab, not by row. Each tab is a complete picture of that platform's three primitives.
// Cloudflare Workflows — three primitives
// 1. step.do — run work and checkpoint the result
const charge = await step.do("charge card", async () => {
return await stripe.charges.create({ amount, customerId });
});
// 2. step.sleep — free suspend for a duration
await step.sleep("cool off", "15 minutes");
// 3. step.waitForEvent — pause until an external event, with a timeout
const approval = await step.waitForEvent<Approval>("await review", {
type: "review.approved",
timeout: "24 hours",
});// AWS Lambda Durable Functions — three primitives
// 1. context.step — run work and checkpoint the result
const charge = await context.step(async (stepCtx) => {
return await stripe.charges.create({ amount, customerId });
});
// 2. context.wait — free suspend for a duration
await context.wait({ minutes: 15 });
// 3. no first class waitForEvent. Pattern: wait + read state,
// or route the callback through Step Functions.
await context.wait({ hours: 24 });
const status = await context.step(async () => {
return await getApprovalState(reviewId);
});The shape is identical across the two. A step that returns a checkpointed value, a wait that suspends, and a way to react to external events. Where they diverge is on the third one. Cloudflare's step.waitForEvent is a real primitive with a first class timeout. AWS does not ship an equivalent in the Node SDK today. The common pattern is to wait for the timeout window and then read state from your own store to decide what happened, or to route the callback through Step Functions and let it invoke the function when the token comes back.
That gap sounds small on paper. In practice it changes how you model any workflow that reacts to a webhook.
wait means the workflow always sleeps the full deadline and reads state on the way out, even when the answer arrived after two seconds.A real workflow · the fifteen minute hold
The demo. A customer POSTs to /reserve with a seat id. The workflow marks the seat as HOLD, waits for the payment webhook, and either confirms the seat or lets it auto expire. Fig. 03 draws the flow once because both platforms implement the same shape.
The interesting branch is the timeout one, drawn with the accent stroke. Without durable execution, "auto expire in fifteen minutes" is a cron sweep, a scheduled task queue, or a Redis key with a TTL and a listener. With durable execution it is the else branch of a single line of code.
Listing 02 is the same shape written out in both dialects.
// worker/reservation.ts
export class TicketReservation extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
const { customerId, seatId } = event.payload;
await step.do("hold seat", async () => {
await this.env.SEATS.put(seatId, JSON.stringify({
status: "HOLD", customerId, at: Date.now(),
}));
});
try {
await step.waitForEvent<PaymentEvent>("await payment", {
type: "payment.completed",
timeout: "15 minutes",
});
await step.do("confirm", async () => {
await this.env.SEATS.put(seatId, JSON.stringify({
status: "CONFIRMED", customerId,
}));
});
} catch {
await step.do("release", async () => {
await this.env.SEATS.delete(seatId);
await notifyBuyer(customerId, "seat released");
});
}
}
}// index.mjs
import { withDurableExecution } from "@aws/durable-execution-sdk-js";
import { ddb, notifyBuyer } from "./deps.mjs";
export const handler = withDurableExecution(async (event, context) => {
const { customerId, seatId } = event;
await context.step(async () => {
await ddb.put({ TableName: "seats", Item: {
seatId, status: "HOLD", customerId,
}}).promise();
});
// The payment webhook writes status=PAID into the same row.
// We wait 15 minutes, then read the row and branch.
await context.wait({ minutes: 15 });
const status = await context.step(async () => {
const row = await ddb.get({ TableName: "seats",
Key: { seatId } }).promise();
return row.Item.status;
});
if (status === "PAID") {
await context.step(async () => {
await ddb.update({ TableName: "seats", Key: { seatId },
UpdateExpression: "SET #s = :c",
ExpressionAttributeNames: { "#s": "status" },
ExpressionAttributeValues: { ":c": "CONFIRMED" },
}).promise();
});
} else {
await context.step(async () => {
await ddb.delete({ TableName: "seats",
Key: { seatId } }).promise();
await notifyBuyer(customerId, "seat released");
});
}
});The Cloudflare version reads the way you'd sketch it on a whiteboard. Wait for the event, or fall through to the catch block on timeout. The AWS version has to sleep the full fifteen minutes and then read the state your payment webhook wrote. That difference costs nothing at fifteen minutes. At fifteen seconds it costs a lot: the AWS workflow always waits the full deadline, even when the customer paid immediately.
await ddb.put(...) between two steps will fire twice, three times, once per replay, and quietly corrupt the seat state.Limits at a glance
Five rows that decide most of the picking. Numbers below are the ones I measured or verified against the docs at time of writing; both platforms move fast, so double check the pages in Cloudflare's limits reference and the Lambda Durable Functions guide.
| dimension | Cloudflare Workflows | AWS Durable Functions |
|---|---|---|
| memory ceiling | 128 MB per instance | up to 10 GB per function |
| cold start | ≈ 1 to 5 ms (isolate) | ≈ 50 to 200 ms (container) |
| concurrent instances | up to 50,000 per account (paid) | ≈ 1,000 per region (reserved) |
| max sleep or wait | 365 days | 365 days |
| sleep cost | free while suspended | free while suspended |
The memory row is the most decisive one. 128 MB is enough for orchestration, JSON reshaping, HTTP calls, and small batches. It is not enough to hold a big pandas frame or run a PDF through OCR in the same process. On AWS you can size the function up to 10 GB and keep the heavy lifting inside the workflow. On Cloudflare you push the heavy work down to a different service (a container, an R2 pipeline, an external worker) and orchestrate from the workflow.
Cold start matters when the workflow chain is short. A five step workflow that runs end to end in two seconds pays the cold start once. A workflow that fans out to a hundred small steps pays it a hundred times if each step wakes a fresh container.
Gotchas on the way
The things that don't show up until you deploy. Some are universal to durable execution as a category; some are platform specific.
| gotcha | where it bites |
|---|---|
| determinism required | Both. Step return values must be JSON serializable and the code outside steps must be deterministic (no Date.now(), no Math.random(), no environment reads that can change between replays). Sneaks up on you the first time you log a timestamp outside a step. |
| side effects inside step.do | Both. A DB write or HTTP call floating between two steps runs on every replay. Wrap it in a step or move it inside the previous one. Silent corruption when you miss it. |
| no first class waitForEvent | AWS Durable Functions. There is no context.waitForEvent in the Node SDK today. Model event waits as context.wait plus a state read, or bounce through Step Functions callback tokens. Cloudflare ships this as one call. |
| memory ceiling | Cloudflare. 128 MB per instance is the isolate limit. Anything heavier (image processing, large DataFrames) moves out to a separate service. AWS lets you size the function up to 10 GB in the same process. |
| cold start delta | AWS. First hit is 50 to 200 ms per fresh container. Reserved concurrency and provisioned concurrency help. Cloudflare's isolate warm up is 1 to 5 ms. Matters most when the step chain is short and latency is visible to a user. |
| concurrency ceiling | AWS. Around 1,000 concurrent executions per region by default (raise via a support ticket). Cloudflare's paid plan tops out at 50,000 concurrent instances per account. Matters when a single event fan out lights up thousands of workflows at once. |
| the naming trap in search | Both. Searching "AWS Durable" turns up Durable Objects tutorials that don't apply. Searching "Cloudflare workflow" turns up general purpose orchestrators (Airflow, Temporal, GitHub Actions). Add the vendor name to every query. Learned this the hard way. |
Reach for X when…
Fig. 06 · the picker
Reach for Cloudflare Workflows when you want the shortest path from idea to a running orchestrator. You are already on Workers, or want to be. You value edge latency, you want step.waitForEvent as a first class primitive, and you can live inside 128 MB per instance by pushing the heavy lifting to R2, D1, or a separate container. The paid plan gives you 50,000 concurrent instances and a proper GA product.
Reach for AWS Durable Functions when you are deep in AWS and want to stay there. You need more than 128 MB of memory in the same process (image processing, ML inference, heavy transforms). You want to compose with EventBridge, Step Functions, SQS, or a hundred other AWS services without leaving IAM. You can absorb the extra 50 to 200 ms of cold start and the 1,000 per region concurrency ceiling.
Closing
Both products are the same category. Both give you steps, waits, and a checkpoint log. Pick the platform, not the primitive. Once you land on a platform, the workflow code looks about the same as anywhere else, and porting between them the day the tradeoff changes is a mostly mechanical exercise.
One last thing. If you take one thing from this piece, let it be that Durable Objects and Durable Functions are not the same thing, and neither is the closest match for the other. The naming is what it is. When you draw architectures across clouds, spell the whole product out. Your future self, and whoever reads the diagram after you, will thank you.
