Building multi-step applications on AWS has always required careful thought around state management, error handling, and coordination between services. If you’ve built workflows before, you know the drill. You end up writing retry logic, checkpointing progress in DynamoDB, guarding against duplicate executions, and managing infrastructure that has nothing to do with your actual business logic.
AWS Lambda durable functions change that. Launched at re:Invent 2025, durable functions extend the familiar Lambda programming model with built-in capabilities to checkpoint progress, automatically recover from failures, and suspend execution for up to one year, all without incurring compute charges during waits.
In this post, I’ll walk you through building a real multi-step application using Lambda durable functions. We’ll build a customer onboarding workflow that validates user data, sends a verification email, waits for the user to confirm, and then provisions their account. By the end, you’ll understand the core concepts and have working code you can adapt for your own use cases.
What Are Lambda Durable Functions?
At their core, durable functions are regular Lambda functions. Same event handler, same integrations, same deployment tools. The difference? When you enable durable execution on a function, you get access to a set of durable operations, primitives like step(), wait(), and waitForCallback(), that automatically handle checkpointing and replay.
Here’s the key mental model:
Your function executes step by step
Each durable operation creates a checkpoint and the result is persisted automatically
If a failure occurs, Lambda re-invokes your function from the beginning
During replay, completed steps are skipped and their stored results are returned instantly
Execution resumes from exactly where it left off
This means you write sequential, readable code while the runtime handles all the complexity of state management and failure recovery underneath.
The Use Case: Customer Onboarding
Let’s build something practical. Our customer onboarding workflow has the following steps:
Validate the incoming registration data
Create a user profile in our database
Send a verification email with a confirmation link
Wait for the user to click the link (up to 24 hours)
Provision the user’s account (or expire the registration)
Send a welcome email
This is a perfect fit for durable functions because it involves:
Multiple sequential steps that depend on each other
A long wait for an external event (email confirmation)
Error handling at each stage
The need to resume reliably after the wait
Without durable functions, you’d need DynamoDB to store state, a separate mechanism to handle the callback, retry logic for each step, and idempotency guards. With durable functions, it’s just code.
Writing the Workflow
Let’s write the actual onboarding logic. This is where durable functions shine. The code reads like a simple script, but it’s fully fault-tolerant.
from aws_durable_execution_sdk_python import (
DurableContext,
StepContext,
durable_execution,
durable_step,
)
from aws_durable_execution_sdk_python.config import (
CallbackConfig,
Duration,
StepConfig,
)
from aws_durable_execution_sdk_python.retries import (
RetryStrategyConfig,
create_retry_strategy,
)
import boto3
import uuid
dynamodb = boto3.resource("dynamodb")
ses = boto3.client("ses")
users_table = dynamodb.Table("Users")
@durable_step
def validate_registration(ctx: StepContext, event: dict) -> dict:
"""Validate incoming registration data."""
email = event.get("email")
name = event.get("name")
if not email or "@" not in email:
raise ValueError(f"Invalid email: {email}")
if not name or len(name) < 2:
raise ValueError(f"Invalid name: {name}")
ctx.logger.info(f"Registration validated for {email}")
return {"email": email, "name": name, "user_id": str(uuid.uuid4())}
@durable_step
def create_user_profile(ctx: StepContext, user: dict) -> dict:
"""Create user profile in DynamoDB."""
users_table.put_item(
Item={
"user_id": user["user_id"],
"email": user["email"],
"name": user["name"],
"status": "pending_verification",
}
)
ctx.logger.info(f"Profile created for user {user['user_id']}")
return user
@durable_step
def send_verification_email(
ctx: StepContext, callback_id: str, user: dict
) -> dict:
"""Send verification email with the callback link."""
verification_link = (
f"https://api.example.com/verify?callback_id={callback_id}"
)
ses.send_email(
Source="noreply@example.com",
Destination={"ToAddresses": [user["email"]]},
Message={
"Subject": {"Data": "Verify your email"},
"Body": {
"Html": {
"Data": f"<p>Hi {user['name']},</p>"
f"<p>Click <a href='{verification_link}'>here</a> "
f"to verify your email.</p>"
}
},
},
)
ctx.logger.info(f"Verification email sent to {user['email']}")
return {"email_sent": True}
@durable_step
def provision_account(ctx: StepContext, user: dict) -> dict:
"""Provision the user's account after verification."""
users_table.update_item(
Key={"user_id": user["user_id"]},
UpdateExpression="SET #s = :s",
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues={":s": "active"},
)
ctx.logger.info(f"Account provisioned for {user['user_id']}")
return {**user, "status": "active"}
@durable_step
def send_welcome_email(ctx: StepContext, user: dict) -> None:
"""Send welcome email to the newly verified user."""
ses.send_email(
Source="noreply@example.com",
Destination={"ToAddresses": [user["email"]]},
Message={
"Subject": {"Data": f"Welcome, {user['name']}!"},
"Body": {
"Html": {
"Data": f"<p>Hi {user['name']},</p>"
f"<p>Your account is now active. Welcome aboard!</p>"
}
},
},
)
ctx.logger.info(f"Welcome email sent to {user['email']}")
@durable_execution
def lambda_handler(event: dict, context: DurableContext) -> dict:
try:
# Step 1: Validate the registration
user = context.step(validate_registration(event))
context.logger.info(f"User validated: {user['email']}")
# Step 2: Create user profile
retry_config = RetryStrategyConfig(max_attempts=3, backoff_rate=2.0)
user = context.step(
create_user_profile(user),
config=StepConfig(
retry_strategy=create_retry_strategy(retry_config)
),
)
# Step 3: Create a callback and send the verification email
callback = context.create_callback(
name="email-verification",
config=CallbackConfig(timeout=Duration.from_hours(24)),
)
context.step(
send_verification_email(callback.callback_id, user)
)
# Step 4: Wait for the user to click the verification link
# Execution suspends here — no compute charges while waiting
verification_result = callback.result()
context.logger.info(f"Callback received: {verification_result}")
if not verification_result or not verification_result.get("verified"):
return {
"user_id": user["user_id"],
"status": "verification_failed",
}
# Step 5: Provision the account
active_user = context.step(
provision_account(user),
config=StepConfig(
retry_strategy=create_retry_strategy(retry_config)
),
)
# Step 6: Send welcome email
context.step(send_welcome_email(active_user))
return {
"user_id": active_user["user_id"],
"status": "active",
"email": active_user["email"],
}
except ValueError as e:
context.logger.error(f"Validation error: {e}")
return {"status": "validation_failed", "error": str(e)}
except Exception as e:
context.logger.error(f"Onboarding failed: {e}")
raise
Let’s break down what’s happening:
Each step is decorated with
@durable_step. This tells the SDK to checkpoint the result. If the function is replayed, completed steps return their stored result instantly.The
@durable_executiondecorator wraps the main handler and provides theDurableContext. This context is your gateway to all durable operations.create_callback()creates a callback token with a 24-hour timeout. We pass thecallback_idinto the verification email as a link parameter. When the user clicks the link, our API Gateway endpoint callsSendDurableExecutionCallbackSuccesswith the callback ID, and execution resumes.callback.result()is where execution suspends. The function terminates, no compute charges accumulate, and Lambda waits for the callback signal. This could take minutes or hours.Retry strategies are configured per step. If
create_user_profileorprovision_accountfails due to a transient DynamoDB error, the step retries automatically with exponential backoff.Error handling distinguishes between terminal errors (invalid registration data → immediate failure) and transient errors (DynamoDB throttling → automatic retry within the step).
Handling the Callback
When the user clicks the verification link, you need an API Gateway endpoint that sends the callback signal to Lambda. Here’s what that looks like:
import boto3
import json
lambda_client = boto3.client("lambda")
def verification_handler(event, context):
"""API Gateway handler for the email verification link."""
callback_id = event["queryStringParameters"]["callback_id"]
lambda_client.send_durable_execution_callback_success(
CallbackId=callback_id,
Result=json.dumps({"verified": True}).encode(),
)
return {
"statusCode": 200,
"body": json.dumps({"message": "Email verified successfully!"}),
}That’s it. One API call resumes the entire onboarding workflow from exactly where it left off.
Best Practices
After building several durable function workflows, here are the patterns I’d recommend:
1. Always Use Lambda Versions in Production
When you deploy a durable function, use explicit versions (not $LATEST). If an execution is suspended waiting for a callback, and you deploy a code update, the replayed invocation will use the version that started the execution. This ensures deterministic replay and prevents inconsistencies.
2. Keep Steps Focused and Idempotent
Each step should do one thing. If a step is retried, it runs the same code again, make sure that’s safe. For example, use conditional writes in DynamoDB (attribute_not_exists) to avoid creating duplicate records.
3. Use Retry Strategies Wisely
Not every step needs retries. Validation steps should fail fast. Steps that call external APIs or databases should have retries with exponential backoff. Configure these per-step:
retry_config = RetryStrategyConfig(
max_attempts=5,
backoff_rate=2.0,
)
context.step(
my_step(data),
config=StepConfig(retry_strategy=create_retry_strategy(retry_config)),
)
4. Use context.logger for Logging
The durable context logger suppresses duplicate logs during replay. If you use print() or a standard logger, you’ll see duplicate log entries every time the function is replayed.
5. Design for Replay
Your code runs from the beginning on every replay. Avoid side effects outside of durable steps, anything not wrapped in context.step() will execute again on every replay. This includes things like incrementing counters, publishing messages, or writing to databases.
When to Use Durable Functions vs. Step Functions
This is the question everyone asks. Here’s my take:
Choose durable functions when:
Your workflow orchestrates Lambda functions (not other AWS services directly)
You want to write and test workflows in your programming language
You have long waits (hours/days) and want zero-cost suspension
Your team is already comfortable with Lambda tooling (SAM, CDK)
Choose Step Functions when:
Your workflow integrates multiple AWS services directly (DynamoDB, SQS, ECS, etc.) via native SDK integrations
You need visual debugging and the execution console for production observability
Non-engineers need to understand and review the workflow
You need the compliance audit trail that Step Functions execution history provides
They’re not mutually exclusive. Many architectures use both: durable functions for code-heavy Lambda chains, Step Functions for multi-service orchestration.
Wrapping Up
Lambda durable functions bring a refreshing simplicity to building multi-step applications. You write sequential code in your preferred language, and the runtime handles checkpointing, retries, and suspension transparently. No external state stores, no retry libraries, no orchestration services to manage.
The customer onboarding workflow we built in this post is a common pattern, but the same approach applies to order processing, payment flows, AI agent orchestration, approval workflows, and any multi-step process that needs to be resilient.
If you want to dive deeper, here are some resources:
Happy building!

