Every serverless developer eventually writes a Lambda that does “just one more thing”, and one day it runs for 15 minutes and gets killed halfway through. If you’ve watched a CloudWatch log end abruptly with Task timed out, you know the feeling.
The trouble is that plenty of real business processes don’t finish in minutes. Think of a month-long trial onboarding sequence, or a document pipeline that hands off to a human reviewer. Until recently, the options for workflows like that on AWS weren’t great:
| Approach | What you get | What it costs you |
|---|---|---|
| AWS Step Functions | A powerful, managed orchestrator | Your logic lives in Amazon States Language, which quickly grows into hundreds of lines of JSON/YAML |
| Hand-rolled state machine | Full control | You persist state in DynamoDB yourself, chain SQS queues together, and hope the retries never loop forever |
AWS announced a third option at re:Invent 2025: AWS Lambda Durable Functions. You write the stateful, long-running workflow as ordinary code, in TypeScript or one of the other supported languages. You don’t write a state machine definition or build your own persistence layer.
Here’s the analogy I find most useful. When you stop halfway through a long novel, you don’t start again from chapter one the next day. You put a bookmark in and carry on from there.
A durable function keeps a bookmark for your code. When the workflow reaches a point where it has to wait, AWS records how far it got and releases the compute. Later, when it’s time to continue, Lambda invokes the function again. The function runs from the top, but every step that already finished is skipped using its recorded result, so execution effectively resumes at the bookmark. The idea is simple, and it changes a lot about what you can build.
Does this make Step Functions obsolete? No. If you’re orchestrating ten AWS services and your team relies on the visual designer, Step Functions is still the better tool. Where durable functions shine is application logic that needs to remember where it was.
Let’s take a typical e-commerce flow. We reserve stock for the order, charge the customer, and then create a shipment. Here’s what that looks like with the durable execution SDK (@aws/durable-execution-sdk-js) in TypeScript:
import { DurableContext, withDurableExecution } from "@aws/durable-execution-sdk-js";
// Placeholder services that talk to your database and external APIs
import { inventoryService, paymentService, shippingService } from "./services";
export const handler = withDurableExecution(
async (event: any, context: DurableContext) => {
const { orderId, amount, items } = event;
// 1. Hold stock for the order
// On failure the SDK retries this step; on success its return value is checkpointed
const inventory = await context.step("reserve-inventory", async () => {
return await inventoryService.reserve(items);
});
// 2. Charge the customer
const payment = await context.step("process-payment", async () => {
return await paymentService.charge(amount);
});
// 3. Book the shipment
// Reached only after both earlier steps have been checkpointed
const shipment = await context.step("create-shipment", async () => {
return await shippingService.createShipment(orderId, inventory);
});
return {
orderId,
status: 'completed',
paymentId: payment.id,
shipmentTracking: shipment.trackingNumber
};
}
);
It looks like a normal async handler, and that’s the point. But this is no longer a fire-and-forget function. It’s a stateful workflow, and three mechanisms make that work.
withDurableExecution does more than decorate the handler. It supervises the execution, records every call you make through context, and keeps a running history of the workflow.
When execution reaches context.step("reserve-inventory", ...), the SDK:
inventory) and persists it to a state store that AWS manages for you, andSay process-payment runs slowly, or the execution environment gets recycled. Lambda invokes the handler again, from the first line. When the code reaches reserve-inventory, the SDK finds that step in the history, skips the callback and returns the stored value. inventory ends up with exactly the same data as before, and the workflow moves on to the step that hasn’t completed yet.
A linear list of steps is only the start. The SDK gives you a handful of primitives for non-linear flows. Your code still reads like one script, but it behaves like a distributed state machine.
context.wait: pausing without paying. This isn’t a sleep(). Your function actually stops running. AWS stores the state and schedules a new invocation for later. You aren’t charged compute while the workflow waits. You can wait for seconds, hours, or until a specific Date, and a single workflow can stay paused for up to a year.
context.waitForCallback: waiting for a person or another system. This is how you implement human-in-the-loop flows. The call produces a unique taskToken, which you hand to something outside the workflow, such as a Slack approval bot, an email link or a button in a React app. The workflow stays paused until that system calls the SendDurableExecutionCallback API with the token.
context.parallel: concurrency that survives failures. Running a plain Promise.all() inside a Lambda is risky, because one slow promise can push the whole invocation past its timeout. With context.parallel, AWS manages the concurrent durable operations for you. Each branch is checkpointed on its own, so if one branch fails, the finished branches keep their results and a retry re-runs only the failed one.
context.invoke: composing workflows. You can call another Lambda function, durable or regular, as a child of the current workflow. That lets you build small reusable sub-workflows (a sendEmailWorkflow, for example) and call them from any parent. Because AWS tracks the parent-child relationship, it can clean up child invocations when the parent is cancelled.
These primitives are powerful, but they come with rules. Ignore them and you’ll get errors that are hard to diagnose.
This is the most important rule. When a workflow resumes, say after a seven-day wait, the handler runs again from the top to find its place. If the code takes a different path or produces different values on that replay, the SDK raises a Non-Deterministic Error.
The usual cause is calling something non-deterministic, such as Math.random() or new Date(), directly in the handler body:
// ❌ BAD: a new value is produced on every replay
const requestId = Math.random();
The fix is to put the call inside a step. The value is generated once, stored in the checkpoint, and returned unchanged on every replay:
// ✅ GOOD: generated on the first run, then read back from history
const requestId = await context.step("get-id", async () => Math.random());
People often assume the timeout has disappeared. It hasn’t. It now applies per invocation rather than per workflow:
context.step still has to finish inside the normal 15-minute Lambda timeout.If one step could run longer than that, split it into smaller steps.
Every step result goes into the execution history, and that history has a size limit. If inventoryService.reserve() returns a huge JSON document, checkpointing will fail. Write large payloads to S3 and return only a reference such as the S3_URL.
A workflow can live for a year, so it’s easy to end up with thousands of executions sitting in a waiting state and adding to your state storage bill. Two habits help:
timeout on wait and waitForCallback operations, so nothing waits indefinitely.RetentionPeriodInDays in the DurableConfig, so the histories of finished executions are cleaned up automatically.These aren’t in the example above, but they follow from how checkpoint-and-replay works:
orderId as an idempotency key to your payment provider, so a retry can’t charge the customer twice.context.step runs again on every replay. Anything that must happen exactly once belongs inside a step.Step Functions is still the strongest low-code orchestration option, especially for direct service integrations such as connecting S3 to Bedrock without writing glue code. Durable functions are the better fit when:
LocalDurableTestRunner is much quicker than deploying a state machine to the cloud for every change.For observability, the Lambda console now has a Durable Executions tab. It shows each execution on a visual timeline, including when it went to sleep and which context.step is currently running. That matters a lot when a workflow runs for weeks.
Do I pay while a workflow is waiting?
You don’t pay for compute during a wait, because the function isn’t running. The stored execution state does have a cost, which is why timeouts and retention settings matter.
Can I use Promise.all inside a durable function?
It’s safer to use context.parallel. It checkpoints each branch, so a failure doesn’t throw away the work the other branches already finished.
What triggers a Non-Deterministic Error?
Anything that makes a replay behave differently from the original run. Common causes are random values, timestamps, or reading changing external state outside a context.step.
context.step, and keep each step under 15 minutes.wait, waitForCallback, parallel and invoke for delays, approvals, fan-out and composition.The biggest win is that you spend your time on business logic instead of plumbing. You don’t write retry loops, checkpoint tables or state-tracking code yourself. You write the function from top to bottom, and AWS keeps it running until the work is done. The 15-minute limit no longer caps how long a workflow can run, so we can build applications for processes that take days or months.