Use Step Functions instead of retry code
I spent months building retry logic into Lambda functions—exponential backoff, dead-letter queues, custom exception handlers scattered across multiple services. Then I realized AWS Step Functions existed to do exactly this work. The moment you stop writing retry code in every Lambda and move that orchestration to Step Functions, your multi-step workflows become testable, debuggable, and maintainable.
The problem with retry logic in Lambda
When you build a typical transaction flow—say, payment processing with inventory updates and notification—your code ends up looking like this: try a payment call, catch the error, wait a bit, try again. Then you need the same pattern in the next service. And the next one. Before you know it, you're managing retry state across multiple functions, and when something fails at step 3 of 7, you have no visual way to see what happened.
Lambda functions are terrible at orchestration because they're designed to do one thing and finish. They lack built-in constructs for branching logic, conditional retries, or waiting between steps. You end up writing boilerplate every time, and worse—you can't visualize the flow or easily step through execution without logs.
Retries and catch blocks per step
Step Functions gives you declarative retry and catch logic at the step level, not buried inside code. Define how many times a step should retry, what errors trigger a retry, and what the backoff strategy is—all in your state machine definition. This moves orchestration concerns where they belong: out of application code.
Here's a realistic example. You're processing an order that requires three steps: charge the card, reserve inventory, and send confirmation. The payment service occasionally returns throttling errors; inventory is flaky on Monday mornings; notifications are fire-and-forget.
{
"Comment": "Order processing workflow",
"StartAt": "ChargePayment",
"States": {
"ChargePayment": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456789:function:charge-payment",
"Retry": [
{
"ErrorEquals": ["ThrottlingException"],
"IntervalSeconds": 2,
"MaxAttempts": 3,
"BackoffRate": 2.0
}
],
"Catch": [
{
"ErrorEquals": ["InsufficientFundsError"],
"Next": "NotifyPaymentFailed"
}
],
"Next": "ReserveInventory"
},
"ReserveInventory": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456789:function:reserve-inventory",
"Retry": [
{
"ErrorEquals": ["ServiceUnavailableException"],
"IntervalSeconds": 5,
"MaxAttempts": 2,
"BackoffRate": 1.5
}
],
"Catch": [
{
"ErrorEquals": ["OutOfStockError"],
"Next": "NotifyOutOfStock"
}
],
"Next": "SendConfirmation"
},
"SendConfirmation": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456789:function:send-confirmation",
"Retry": [
{
"ErrorEquals": ["States.TaskFailed"],
"IntervalSeconds": 10,
"MaxAttempts": 1
}
],
"End": true
},
"NotifyPaymentFailed": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456789:function:notify-payment-failed",
"End": true
},
"NotifyOutOfStock": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456789:function:notify-out-of-stock",
"End": true
}
}
}Notice what you've gained: the payment Lambda doesn't know about retry logic. It throws ThrottlingException, and Step Functions handles the retry with exponential backoff. The inventory service fails differently, so it gets different retry settings. Your Lambda code stays focused on its job—processing a single step—while Step Functions orchestrates the flow.
The Catch block lets you branch to different paths based on specific errors. If the card was declined, you go to NotifyPaymentFailed. If inventory is out of stock, you go to NotifyOutOfStock. Each path is a first-class state with its own logic. Your Lambda functions return clean errors; Step Functions decides what to do with them.
Visual execution history
Open the Step Functions console and watch your workflow execute. Every state transition shows up as a visual node. You can click on any execution and see the exact input and output at each step, the time spent, and why it failed if something went wrong. This visibility is priceless when debugging production issues.
I had an order that failed halfway through—payment went through, inventory was reserved, but the confirmation never sent. In a traditional Lambda setup, I'd be digging through CloudWatch logs across three services, trying to piece together the timeline. With Step Functions, I opened the execution history and immediately saw:
ChargePaymentsucceeded in 1.2 seconds, returnedtransactionId: tx-12345ReserveInventorysucceeded in 0.8 seconds, returnedreservationId: res-789SendConfirmationfailed after 3 retry attempts, final error:EmailServiceTimeoutException
The visual graph shows the entire workflow. The execution detail view shows the JSON passed between steps. I could immediately see the email service was timing out and adjust the retry policy or increase the timeout for that step. No log parsing required.
This is especially valuable in teams. New developers can understand the workflow by looking at the graph. Product managers can see where delays happen. Operations can spot patterns—if ReserveInventory always takes longer on weekends, you have visibility to that.
Human approval steps
Not every workflow is fully automated. Sometimes you need a human to review something before proceeding—approving a refund, reviewing a payment dispute, or validating a data migration. Step Functions has a built-in Task type for this: the callback pattern with SNS or SQS integration.
Imagine a refund request over a certain threshold requires manual review. Your state machine pauses at an approval step, sends a message to an SNS topic that triggers an email to your finance team, and waits. Someone clicks a link or your system receives an approval event, and the workflow resumes from that exact point with the decision included.
{
"StartAt": "CheckRefundAmount",
"States": {
"CheckRefundAmount": {
"Type": "Choice",
"Choices": [
{
"Variable": "$.amount",
"NumericGreaterThan": 1000,
"Next": "RequestApproval"
}
],
"Default": "ProcessRefund"
},
"RequestApproval": {
"Type": "Task",
"Resource": "arn:aws:states:::sns:publish.waitForTaskToken",
"Parameters": {
"TopicArn": "arn:aws:sns:us-east-1:123456789:refund-approvals",
"Message": {
"executionName.$": "$$.Execution.Name",
"amount.$": "$.amount",
"taskToken.$": "$$.Task.Token"
}
},
"Next": "ProcessRefund"
},
"ProcessRefund": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456789:function:process-refund",
"End": true
}
}
}The workflow pauses at RequestApproval and waits—could be minutes, hours, or days. Once approved, your approval service sends the task token back to Step Functions with the approval decision, and the workflow resumes at ProcessRefund. The refund amount and execution details are all preserved. No custom approval tracking database needed.
Easier debugging of multi-step flows
When a step fails, Step Functions tells you exactly which state, what error was thrown, and what the input and output were at that point. You don't have to correlate logs across services or try to reconstruct the flow from Lambda invocation IDs.
Compare these two scenarios:
Without Step Functions: Lambda A calls Lambda B, which calls Lambda C. C fails and logs an error. You check CloudWatch for Lambda C, see the error, but don't immediately know what data was passed to it. You have to trace back through Lambda B's logs to see what Lambda A returned. If there's a timeout or a service wasn't reachable, you might not even see a clear error—just a generic "invoke failed" message.
With Step Functions: You open the execution details and see the exact input passed to the failing step, the exact error returned, and how many times it retried. The visual graph shows you where in the workflow it failed. You can test the failing step in isolation by manually triggering a new execution with the same input.
Step Functions also integrates with X-Ray, so you can trace the entire workflow across services and see where latency is coming from. A single execution detail view gives you the full picture.
Common mistakes
Putting too much logic in a single state. A state should do one thing. If your Lambda is doing three different operations, split it into three states. This keeps individual functions simple and makes it easy to adjust retry policies per operation.
Not setting timeout values. By default, Step Functions will wait forever for a task to complete. If your Lambda hangs or a service is unresponsive, the execution stalls. Always set TimeoutSeconds on tasks so you fail fast and trigger retries or fallback logic.
Ignoring error types. Catching all errors with "ErrorEquals": ["States.ALL"] is tempting but hides bugs. Catch specific errors and route them to appropriate handlers. Let unexpected errors bubble up and fail the execution so you see something is wrong.
Over-retrying transient failures. Not every error should be retried. A ValidationError from bad input won't be fixed by retrying. A ThrottlingException will. Know the difference and configure retries accordingly.
Key takeaways
- Move retry logic out of Lambda code and into Step Functions state machines where it's declarative and reusable.
- Use
RetryandCatchblocks at the step level to handle specific errors with appropriate backoff strategies. - Leverage the visual execution history and detailed state tracking for debugging—you get visibility your Lambda logs alone can't provide.
- Use the callback pattern with task tokens for human approval steps without building custom approval infrastructure.
- Split workflows into small, focused states so each Lambda does one thing and Step Functions handles orchestration.






Comments
Loading comments…