DEV Community

Cover image for AWS Lambda Durable Functions for .NET: Long-Running Workflows
Sabarish Sathasivan for AWS Community Builders

Posted on • Edited on

AWS Lambda Durable Functions for .NET: Long-Running Workflows

On July 23, 2026, AWS announced the general availability of the Lambda Durable Execution SDK for .NET.

For .NET developers, AWS Step Functions has been the usual AWS-managed choice for long-running orchestration. Lambda durable functions now provide another option: keeping the workflow in C# alongside the application logic.

This article uses an order-processing workflow written in C# to explain how Lambda durable functions work in .NET, including checkpointing, replay, callbacks, idempotency, timeouts, and retries.

The complete sample is available in the ssathasivan/LambdaDurablefuctionsDotNet repository.

Example workflow

Consider an order-processing workflow with the following rules:

  1. An order arrives in the system.
  2. If the order value is $500 or less, process it immediately.
  3. If the order value exceeds $500, require manager approval.
  4. If the manager declines, stop processing.
  5. If nobody responds within three days, expire the order.

A graphical representation of the workflow is shown below.

validate order
      |
amount > $500? ----------------------+
      |                              |
      no                            yes
      |                              |
      |                    wait for manager callback
      |                              |
      |                    declined or timeout -> stop
      |                              |
      |                           approved
      |                              |
      +--------------+---------------+
                     |
                complete order
                     |
                 completed
Enter fullscreen mode Exit fullscreen mode

Architecture

Order workflow Architecture

The application flow is:

  • API Gateway -> OrderApi -> stores the order in DynamoDB and starts OrderWorkflow.
  • OrderWorkflow -> validates the order and waits for approval when required.
  • Manager approval -> OrderApi -> resumes OrderWorkflow using the callback ID.
  • OrderWorkflow -> DynamoDB -> records the final order status.

Before looking at how the workflow runs, it helps to understand a few terms used by Lambda durable functions.

Key terms

A durable execution may involve multiple Lambda invocations. These terms are important when reasoning about the workflow:

Term What it means
Durable execution One end-to-end run of the workflow, from the initial invocation to the final result. Lambda may use multiple function invocations to complete it, and the execution can remain active for up to one year.
Durable operation A workflow action performed through the durable context rather than as a regular method call. Durable operations manage work across multiple invocations. Refer to the Durable operations table below for the full list.
Checkpoint A persisted record associated with a durable operation, containing information such as its type, name, input, result, error, and status. Lambda uses the checkpoint log to reconstruct execution state during replay.
Suspend and resume When the workflow waits for a timer, callback, or retry delay, the current invocation can end and later resume in another invocation. Lambda starts another invocation when the workflow is ready to continue.
Replay When an execution resumes, the handler runs again from the beginning. Completed durable operations return their checkpointed state or results instead of repeating completed work.

Durable operations

This workflow uses two durable operations: StepAsync and WaitForCallbackAsync. The .NET SDK provides the following operations:

.NET API Purpose
CreateCallbackAsync Create a callback for an external system to complete.
InvokeAsync Invoke another durable Lambda function and await its result.
MapAsync Apply a function to each item, with optional concurrency control. Item processing can be retried or replayed.
ParallelAsync Execute multiple named branches concurrently.
RunInChildContextAsync Run a user function inside a sub-workflow.
StepAsync Execute a step with automatic checkpointing.
WaitAsync Suspend until a timer expires.
WaitForCallbackAsync Suspend until an external system responds.
WaitForConditionAsync Poll a condition with durable delays.

Timeouts

A durable function has two separate timeouts.

The function timeout applies to a single Lambda invocation. It is the same setting a standard Lambda function has, and it still has the same maximum of 15 minutes.

The execution timeout applies to the whole durable execution, from the first invocation to the final result, including waits, retries, callbacks, and replay. It can be set up to one year, and it defaults to 24 hours.

Managers have up to three days to respond. A Lambda invocation can run for only 15 minutes, so the workflow suspends while it waits and resumes when a decision arrives. The default execution timeout is not long enough for this scenario, so the CDK stack increases it to seven days:

var function = new Function(this, "OrderWorkflowFunction", new FunctionProps
{
    Timeout = Duration.Seconds(30), // One Lambda invocation
    DurableConfig = new DurableConfig
    {
        ExecutionTimeout = Duration.Days(7), // The complete workflow
        RetentionPeriod = Duration.Days(7),  // History after completion
    },
});
Enter fullscreen mode Exit fullscreen mode

Implementation

The excerpts below focus on the durable workflow and the parts that handle callbacks and idempotency.

Durable workflow

The workflow is written as sequential C#. Work that interacts with an external system runs inside a durable operation so Lambda can checkpoint its result.

The following excerpt is from OrderFunction. Its Lambda handler passes WorkflowAsync to DurableFunction.WrapAsync<OrderRequest, OrderResult>.

    private const decimal ManualApprovalThreshold = 500m;

    public static async Task<OrderResult> WorkflowAsync(
        OrderRequest order,
        IDurableContext ctx)
    {
        // The validation result is checkpointed. During replay, a completed step
        // returns the stored result instead of validating the order again.
        var validation = await ctx.StepAsync(
            async (_, ct) => await CatalogService.ValidateAsync(order, ct),
            name: "validate-order");

        if (!validation.IsValid)
        {
            return new OrderResult(
                order.OrderId,
                "Rejected",
                RejectionReason: validation.Reason);
        }

        if (order.Amount > ManualApprovalThreshold)
        {
            ApprovalDecision decision;

            try
            {
                decision = await ctx.WaitForCallbackAsync<ApprovalDecision>(
                    async (callbackId, _, ct) =>
                        await OrderService.RecordAwaitingApprovalAsync(
                            order.OrderId,
                            callbackId,
                            ct),
                    name: "manager-approval",
                    config: new WaitForCallbackConfig
                    {
                        Timeout = TimeSpan.FromDays(3),
                        // This retries the callback submitter (the DynamoDB write),
                        // not the three-day wait.
                        RetryStrategy = RetryStrategy.Exponential(maxAttempts: 3),
                    });
            }
            catch (CallbackTimeoutException)
            {
                return new OrderResult(
                    order.OrderId,
                    "Expired",
                    RejectionReason: "No approval within 3 days");
            }

            if (!decision.Approved)
            {
                return new OrderResult(
                    order.OrderId,
                    "Rejected",
                    RejectionReason: $"Declined by {decision.ApprovedBy}");
            }
        }

        // A step body may run more than once because of retries or an interruption
        // before its result is checkpointed. The downstream service must enforce
        // the idempotency key.
        var confirmation = await ctx.StepAsync(
            async (_, ct) => await OrderService.CompleteAsync(
                order.OrderId,
                idempotencyKey: order.OrderId,
                ct: ct),
            name: "complete-order",
            config: new StepConfig
            {
                // This example retries every exception for brevity. A production
                // workflow should retry only transient failures.
                RetryStrategy = RetryStrategy.Exponential(
                    maxAttempts: 3,
                    initialDelay: TimeSpan.FromSeconds(2)),
            });

        return new OrderResult(
            order.OrderId,
            "Completed",
            confirmation.ConfirmationId);
    }
Enter fullscreen mode Exit fullscreen mode

The status-writing steps and supporting models are omitted here. They are available in the repository.

WaitForCallbackAsync creates the callback and runs the submitter that stores its ID in DynamoDB. The retry strategy applies to that submitter, not to the three-day wait.

Idempotent completion

A step may run again if it is retried or interrupted before its result is checkpointed. The completion update therefore uses a DynamoDB condition:

try
{
    await Dynamo.UpdateItemAsync(new UpdateItemRequest
    {
        TableName = table,
        Key = Key(orderId),
        UpdateExpression =
            "SET #status = :completed, ConfirmationId = :cid",
        ConditionExpression = "attribute_not_exists(ConfirmationId)",
        ExpressionAttributeNames =
            new Dictionary<string, string> { ["#status"] = "Status" },
        ExpressionAttributeValues = new Dictionary<string, AttributeValue>
        {
            [":completed"] = new() { S = "Completed" },
            [":cid"] = new() { S = confirmationId },
        },
    }, ct);
}
catch (ConditionalCheckFailedException)
{
    // The order was already completed. Return the stored confirmation.
    var existing = await Dynamo.GetItemAsync(new GetItemRequest
    {
        TableName = table,
        Key = Key(orderId),
        ProjectionExpression = "ConfirmationId",
    }, ct);

    confirmationId = existing.Item["ConfirmationId"].S;
}
Enter fullscreen mode Exit fullscreen mode

The first attempt stores the confirmation. If the step runs again, the condition fails and the existing confirmation is returned.

Starting and resuming the workflow

The API Lambda starts a durable execution with an asynchronous invoke. It uses the order ID as the durable execution name, giving repeated submissions a stable idempotency key:

var invoke = new InvokeRequest
{
    FunctionName = DurableFunctionArn,
    InvocationType = InvocationType.Event,
    DurableExecutionName = orderId,
    Payload = JsonSerializer.Serialize(workflowInput, Json),
};

await Lambda.InvokeAsync(invoke);
Enter fullscreen mode Exit fullscreen mode

Durable functions must be invoked through a version or alias. Using an alias also keeps new executions pinned to a published function version.

When a manager responds, the API Lambda sends the decision back as the callback result:

var decision = new ApprovalDecision(
    body.Approved,
    body.ApprovedBy,
    body.Comment);

var resultJson = JsonSerializer.Serialize(decision, Json);

await Lambda.SendDurableExecutionCallbackSuccessAsync(
    new SendDurableExecutionCallbackSuccessRequest
    {
        CallbackId = body.CallbackId,
        Result = new MemoryStream(Encoding.UTF8.GetBytes(resultJson)),
    });
Enter fullscreen mode Exit fullscreen mode

Both approval and denial use the callback-success API because the callback itself completed successfully. The workflow interprets the Approved property as the business decision. The callback-failure API is reserved for cases where the external approval operation itself failed.

How Lambda durable functions run the code

One durable execution is one run of the business workflow, but it may span multiple Lambda invocations. Waits, retry delays, callbacks, and runtime interruptions can all end the current invocation and cause Lambda to start another one later.

The order amount determines the normal path through this example.

A small order: one invocation on the normal path

An order for $120 arrives. Lambda calls Handler, which passes WorkflowAsync to WrapAsync, and the workflow runs from top to bottom without suspending:

  • validate-order executes, and the SDK checkpoints the ValidationResult.
  • order.Amount > ManualApprovalThreshold is false, so the approval block is skipped.
  • complete-order executes, and its OrderConfirmation is checkpointed.
  • The workflow returns Completed.

Nothing needs to wait, so the durable execution normally completes in one invocation. Checkpoints are still written, but no replay is required on this path.

Small order ($120) - normal path
--------------------------------
validate-order   -> execute, checkpoint result
amount <= $500   -> skip approval
complete-order   -> execute, checkpoint result
return Completed
Enter fullscreen mode Exit fullscreen mode

A large order: suspend and resume

An order for $4,000 takes the approval branch. A single Lambda invocation can run for at most 15 minutes, so a manager who may respond in three days cannot be waited for inside one invocation. The workflow suspends instead.

On the first invocation:

  • validate-order executes and is checkpointed.
  • manager-approval creates a callback ID.
  • The callback submitter records the callback ID and AwaitingApproval status in DynamoDB.
  • The durable execution suspends, and the current invocation ends.

The current invocation ends while the workflow waits, and the idle wait does not incur Lambda compute charges.

When the manager responds through the API, the API Lambda calls SendDurableExecutionCallbackSuccess. Lambda then starts another durable-function invocation and runs WorkflowAsync from the beginning:

  • validate-order returns its checkpointed result without calling CatalogService.ValidateAsync again.
  • manager-approval returns the callback result.
  • If the manager approved the order, complete-order is the first operation without a terminal checkpoint, so it executes and stores its result.
  • The workflow returns Completed.
Large order ($4,000) - approved path
------------------------------------
First invocation
  validate-order   -> execute, checkpoint result
  manager-approval -> create callback, record AwaitingApproval, suspend

     ... hours or days pass without compute charges ...

API Lambda
  approval request -> send callback result

Resumed invocation
  validate-order   -> return checkpointed result
  manager-approval -> return callback result: approved
  complete-order   -> execute, checkpoint result
  return Completed
Enter fullscreen mode Exit fullscreen mode

A timeout follows a different resumed path. When the three-day callback timeout expires, WaitForCallbackAsync raises CallbackTimeoutException, and the workflow returns Expired. It does not continue to complete-order.

Large order ($4,000) - timeout path
-----------------------------------
First invocation
  validate-order   -> execute, checkpoint result
  manager-approval -> create callback, record AwaitingApproval, suspend

     ... three days pass without a response ...

Resumed invocation
  validate-order   -> return checkpointed result
  manager-approval -> callback timeout
  return Expired
Enter fullscreen mode Exit fullscreen mode

Replay

During replay:

  • Ordinary C# between durable operations runs again.
  • Completed operations return their checkpointed results.
  • Operation bodies may still run more than once because of retries or interruptions.

This means conditions such as if (order.Amount > ManualApprovalThreshold) are evaluated again when the workflow resumes. Workflow code must therefore remain deterministic and produce the same sequence of durable operations for the same input and checkpoint history.

See Determinism during replay for more information.

Design gotchas

Lambda durable functions handle checkpoints, replay, and waits. Your application still owns these concerns:

  • Idempotency: A checkpoint does not provide exactly-once side-effect semantics. The sample uses the order ID at two boundaries: as DurableExecutionName to avoid starting duplicate workflows, and in a DynamoDB condition to avoid completing an order twice.
ConditionExpression = "attribute_not_exists(ConfirmationId)"
Enter fullscreen mode Exit fullscreen mode
  • Replay safety: Ordinary workflow code runs again during replay. Capture changing values such as time, GUIDs, random numbers, and feature-flag results inside durable operations.
var createdAt = await ctx.StepAsync(
    (_, _) => Task.FromResult(DateTimeOffset.UtcNow),
    name: "capture-created-time");
Enter fullscreen mode Exit fullscreen mode
  • Retries: Retry only transient failures and make every retried side effect idempotent. In the callback configuration, the retry strategy applies to the submitter that stores the callback ID; it does not retry or extend the wait itself.

  • Callback security: Treat a callback ID as a secret capability. The sample returns it from the status endpoint to keep the design self-contained, but a production API needs authentication, authorization, audit logging, and protection against leaking the ID.

  • Timeout alignment: The callback timeout must fit inside the durable execution timeout. Also consider the lifetime of external tickets, approval links, and any data the workflow expects to read after it resumes.

  • Versioning: Operation names are part of persisted execution history. Renaming manager-approval or changing the order of operations can break replay for in-flight executions. Publish numbered versions and invoke through an alias.

  • Operation boundaries: Use durable operations for external work, waits, retries, or values that must survive across invocations. Keep pure, deterministic decisions as normal C#.

Durable functions reduce orchestration code, but idempotency, security, consistency, and workflow evolution remain application responsibilities.

Sources

Top comments (1)

Collapse
 
ranjith-ramakrishnan profile image
Ranjith kumar Ramakrishnan

Thanks for sharing the article with an example and the Github repository. Are you planning to deploy any Lambda Durable function in production.