12 minute read

Back in 2021 I wrote two posts about Step Functions with CDK, and in both of them my main argument for using it was communication. You draw the workflow, you show it to the business analyst, and you are both looking at the same thing. Then at re:Invent 2025 AWS announced Lambda durable functions, which let you write the same kind of long running, multi step workflow as plain code inside a Lambda function. A year later the SDK is available for TypeScript, Python, Java, Go and .NET and the limits went up in October, so I wanted to know if my old argument still holds and if we still need Step Functions at all. To find out I built the same workflow twice, once with each, and compared them side by side.

The workflow

I wanted something small enough to read in one go, but with the parts that make workflows hard. It is an order flow. Reserve the inventory, charge the payment, and if the amount is over 1000 wait for a manager to approve it. Then wait a short cool-off period, ship the order and mark it as shipped. If the approval is rejected or the shipping fails, refund the payment and release the inventory. So there is a human in the loop, a timer, a retry and a compensation path, which covers most of what you would use either service for.

To keep the comparison fair, both versions call exactly the same business logic from one shared file. The only thing that differs is the orchestration.

The durable function version

This is the whole workflow as a durable function, with the logging left out:

export const handler = withDurableExecution(async (order: Order, context: DurableContext) => {
  await context.step("reserve-inventory", async () => reserveInventory(order, "durable"));

  const payment = await context.step("charge-payment", async () => chargePayment(order), {
    semantics: StepSemantics.AtMostOncePerRetry,
  });

  try {
    if (order.amount > APPROVAL_LIMIT) {
      await context.waitForCallback(
        "manager-approval",
        async (callbackId) => saveApprovalToken(order.orderId, callbackId),
        { timeout: { hours: 24 } },
      );
    }

    await context.wait("cool-off-period", { seconds: 20 });

    const shipment = await context.step("ship-order", async () => shipOrder(order), {
      retryStrategy: (_error, attempt) => ({ shouldRetry: attempt < 3, delay: { seconds: 2 } }),
    });

    await context.step("mark-shipped", async () => setStatus(order.orderId, "SHIPPED"));
    return { orderId: order.orderId, status: "SHIPPED", ...shipment };
  } catch (error) {
    await context.step("refund-payment", async () => refundPayment(payment.paymentId));
    await context.step("release-inventory", async () => setStatus(order.orderId, "CANCELLED"));
    return { orderId: order.orderId, status: "CANCELLED" };
  }
});

If you have written any async TypeScript before, this reads like normal code. The approval is a plain if, the compensation is a catch block and the retry strategy is a small function. You wrap the handler with withDurableExecution from the @aws/durable-execution-sdk-js package and you get a DurableContext with operations like step, wait, waitForCallback, parallel, map and invoke. On the infrastructure side the only difference to a normal function is one property in CDK:

const durableFn = new NodejsFunction(this, "DurableOrder", {
  entry: "src/durable/order.ts",
  durableConfig: {
    executionTimeout: Duration.days(2),
    retentionPeriod: Duration.days(7),
  },
});

Please note that you can only set this when the function is created. If you add it to an existing function, CDK will replace the function, and if you have set an explicit functionName the deployment fails. CDK also attaches the AWSLambdaBasicDurableExecutionRolePolicy managed policy for you, which covers the checkpoint APIs.

The Step Functions version

The same flow as a state machine ends up with eleven states. I wrote it with the CDK L2 constructs, and the definition alone is about 80 lines, plus four small Lambda handlers for the parts that are not a direct service integration. Here is the part that shows the shape of it:

managerApproval.addCatch(compensate, { resultPath: "$.error" });
shipOrder.addCatch(compensate, { resultPath: "$.error" });

const definition = reserveInventory
  .next(chargePayment)
  .next(
    new sfn.Choice(this, "Needs approval?")
      .when(sfn.Condition.numberGreaterThan("$.amount", 1000), managerApproval.next(afterApproval))
      .otherwise(afterApproval),
  );

It is more code, and some of it is JSONPath plumbing that has nothing to do with orders. On the other hand, two of the states, reserving the inventory and updating the status, call DynamoDB directly without any Lambda function in between, and I get the graph in the console without doing anything. I come back to both of those further down, since in my opinion they are the strongest arguments for Step Functions.

Replay is the one thing you have to understand

This is where durable functions are different from anything else in Lambda, and the place where I expect most people to make mistakes in the beginning. A durable function does not sit there and wait. When it reaches a wait or a callback, Lambda checkpoints what has happened so far and the invocation ends. When the timer fires or the callback arrives, Lambda invokes your function again and runs your handler from the very top. Every step that already finished is skipped and its stored result is returned instead, until the code reaches the point where it stopped. AWS calls this checkpoint and replay.

The consequence is that any code outside a step runs again on every replay. In the demo I put two lines at the top of the handler to make that visible:

console.log(`[console.log] handler body running for ${order.orderId}`);
context.logger.info("handler body running", { orderId: order.orderId });

For an order that waits for an approval and then for the cool-off timer, the console.log line shows up three times in CloudWatch, once per invocation. The context.logger line shows up once, because the SDK logger knows when it is replaying and stays quiet. So use context.logger and not console.log.

The same goes for anything that is not deterministic. I generate two random references, one outside a step and one inside:

const unsafeRef = Math.random().toString(36).slice(2, 8);
const safeRef = await context.step("create-reference", async () => Math.random().toString(36).slice(2, 8));

unsafeRef is different on every replay, while safeRef is checkpointed after the first run and stays the same. The same applies to Date.now(), UUIDs and any API call whose result you base a decision on, so anything that can return a different value the next time should go into a step.

Steps themselves also need some thought. By default a step is at least once per retry, which means that if the function is interrupted after the work ran but before the checkpoint was written, it runs again. For a payment that is not what you want, so the charge step uses StepSemantics.AtMostOncePerRetry. If the function gets interrupted in the middle of that step, it does not run it again on replay, it throws an error instead and you decide what to do. This is a real difference to Step Functions. A Standard workflow is documented as exactly once, every state runs once, and you never have to think about which part of your code runs again. It only becomes your problem when you add a Retry to a task yourself. Either way, your payment provider should get an idempotency key, but with durable functions you have to understand the replay model to write correct code, and with Step Functions you mostly do not.

Waiting for a human

Here the two are closer than I expected. Step Functions has had the callback pattern for years. You use WAIT_FOR_TASK_TOKEN, it hands a token to your Lambda, you send that token to whoever needs to approve, and somebody calls SendTaskSuccess or SendTaskFailure with it. Durable functions do the same thing with waitForCallback. You get a callback ID, you send it somewhere, and somebody calls SendDurableExecutionCallbackSuccess or SendDurableExecutionCallbackFailure:

aws lambda send-durable-execution-callback-success \
  --callback-id "$CALLBACK_ID" \
  --cli-binary-format raw-in-base64-out \
  --result '{"approvedBy":"manager"}'

aws stepfunctions send-task-success \
  --task-token "$TASK_TOKEN" \
  --task-output '{"approvedBy":"manager"}'

In both cases nothing runs while you wait, and in both cases a failure from the approver lands in your error handling, a catch block in one and a Catch on the state in the other. Durable functions also support heartbeats for long callbacks, the same way Step Functions does for task tokens. If this is the only reason you are looking at either service, both will do the job.

Integrations

In my opinion this is the biggest difference, and AWS says so in their own comparison as well. Step Functions integrates with more than 220 AWS services, so a state can call DynamoDB, SQS, SNS, ECS, Glue, Bedrock and so on directly, without any code. In my workflow two of the eleven states do that. A durable function has nothing like it, every call to another service is SDK code inside a step that runs with the function’s role. That is fine when your logic is in Lambda anyway, but if your workflow is mostly gluing AWS services together, you end up writing and maintaining code that Step Functions would have handled for you.

On top of that, some of the optimized integrations support the run a job pattern, .sync, where Step Functions starts a Glue job, an ECS task, a Batch job or a SageMaker training job and waits until it is finished. With a durable function you would build that yourself, with waitForCondition polling the job status or with a callback that EventBridge triggers when the job is done. And if some of your work runs outside AWS, Step Functions has activities, which are workers that poll for tasks from wherever they run.

The same is true for large fan-out. context.map and context.parallel look like Distributed Map, but they run inside the same function invocation with child contexts. They do not start separate Lambda functions, so if you need to process millions of objects from S3, Distributed Map is still what I would use.

Versioning and in-flight executions

Both services pin a running execution to the code it started with, but they get there differently. A Step Functions execution keeps the definition it started on, so you can deploy a new version of the state machine while old executions finish on the old one. With durable functions you should always invoke through a version or an alias. In the demo I invoke a live alias, and you can see the version number in the execution ARN. If you deploy while an order is waiting for an approval, that order replays on the old version and new orders go to the new one.

What you have to be careful with is changing step names or behaviour in a way that breaks replay. The SDK matches the saved checkpoints to your steps when it replays, so renaming charge-payment to charge while executions are in flight can make them fail to resume, or even produce wrong results. If you invoke the unqualified function instead of an alias, you run into exactly that.

Limits

Before the numbers, a word on Express workflows, because everything in this post is about Standard. Express workflows run for at most five minutes, are at least once, only support the request response pattern and are priced per execution and duration instead of per transition. They are built for high volume event processing and do not really compete with durable functions for long running work, so I left them out of the comparison.

Both Standard workflows and durable functions can run for up to a year. Beyond that the limits are different enough to matter for some workloads. A Step Functions Standard execution has 256 KiB per state input or output and 25,000 events in its history. A durable execution can hold 1 MB per operation, up to 30,000 operations and 100 MB of persisted data in total. Those durable limits were 256 KB and 3,000 operations at launch and were raised in October 2026, so if you read an older post that says otherwise, that is why. Each invocation of a durable function is still a Lambda invocation, so a single run between two checkpoints is bound by the normal 15 minute timeout.

One more thing worth knowing, Lambda does not automatically retry a durable execution that fails after an asynchronous invocation, which is different from normal async Lambda. AWS recommends a dead letter queue on the function and an EventBridge rule on the failed, stopped and timed out states. Step Functions gives you redrive for failed executions instead, for up to 14 days.

There is also maintenance. Step Functions has no runtime, so there is nothing to patch. A durable function is a Lambda function, so when Node.js 22 reaches end of support, upgrading the runtime is your job, and with executions that can run for months, so is making sure the new version can still replay the old checkpoints. AWS lists this in their own comparison as one of the reasons to pick Step Functions.

Cost

The pricing models are different, so here is my rough math for this workflow, at us-east-1 list prices, without the free tier. A Step Functions Standard workflow costs $0.025 per 1,000 state transitions. The happy path goes through seven states, so a million orders comes to about $175 for the orchestration, plus the two small Lambda functions it calls.

A durable function pays normal Lambda compute and requests, plus $8.00 per million durable operations, $0.25 per GB of checkpoint data written and $0.15 per GB-month retained. The happy path is one execution start, five steps and one wait, so seven operations, which makes $56 per million orders. The payloads are tiny, so the data charges are a few cents. On top of that comes the compute for two invocations per order, the first run and the replay after the wait, which is somewhere around one or two dollars per million with my 128 MB functions on ARM. So roughly $58 against roughly $175.

I would not choose based on that alone. At a few thousand orders a month both are rounding errors. It matters when you have very high volume, or a workflow with many small states, where the per transition price of Step Functions adds up. Keep in mind that replays cost compute, so a durable function with a lot of expensive code outside of steps, or with many short waits, gets more expensive than this example.

Which one would I pick

For this order flow I would go with the durable function. The logic is in Lambda anyway, the people maintaining it would be developers, I can unit test it with the tools I already use, and the code is about half the size. That is a verdict for this workflow though, and not for every workflow. A pipeline that mostly starts Glue jobs and waits for them, or one that needs Distributed Map, would look very different. I went through the use cases one by one in a separate post, including the hybrid setup where you use both.

Please note that the numbers above are what I could verify in the AWS documentation and pricing pages in November 2026. Durable functions are still moving quickly, the limits already changed once this year, and even the AWS docs do not fully agree with each other yet. The comparison page still lists only JavaScript, TypeScript and Python, while the SDK page lists Java, Go and .NET as well. So please check before you quote any of it. And if you have run durable functions in production, I would really like to hear how replay has worked out for you.

Thank you very much for reading this post. Pedram