AWS Step Functions Deep Dive: Building Resilient Workflow Orchestration
Build production serverless workflows with Step Functions: Standard vs Express, Distributed Map, error handling, and cost optimization with working CDK examples.
AWS Step Functions moves orchestration out of Lambda code and into a state machine, but two decisions shape everything that follows: Standard or Express workflows, and how much error handling to push into the state machine. For high-volume work that finishes in minutes, Express is the right default. Standard earns its higher per-transition price when a workflow runs for days, needs exactly-once semantics, or has to leave an audit trail.
Getting those two decisions right is most of the value. The rest is a small set of patterns that hold up in production: Distributed Map for datasets too large for a regular Map state, Task Token callbacks for human approvals, direct service integrations that skip the Lambda wrapper, and a cost model you can compute before you deploy.
The Orchestration Challenge
Building complex serverless workflows with Lambda alone creates maintenance challenges. Orchestration logic that lives inside Lambda functions balloons quickly: hundreds of lines managing retries, error handling, state tracking, and conditional branching. Debugging production issues means parsing CloudWatch logs to reconstruct execution flows. Adding new steps requires code changes and redeployments.
The real complexity emerges when handling:
- Multi-step processes: Order processing with validation, payment, inventory updates, shipping coordination
- Error recovery: Implementing exponential backoff, circuit breakers, and compensating transactions in application code
- State management: Tracking workflow state across Lambda invocations using DynamoDB or Redis adds latency and cost
- Parallel processing: Coordinating concurrent tasks while managing failures and aggregating results
- Human approvals: Implementing callback mechanisms for long-running approval processes
- Scale: Processing millions of items hits Lambda timeout limits without proper orchestration
Step Functions addresses these challenges with visual workflows, built-in error handling, and AWS service integrations. But choosing between Standard and Express workflows, understanding pricing implications, and implementing production patterns requires navigating documentation spread across multiple sources.
Understanding Workflow Types
The fundamental decision for Step Functions is choosing between Standard and Express workflows. This choice impacts cost, execution model, and visibility.
Standard Workflows
Standard workflows provide exactly-once execution guarantees with full execution history:
import * as cdk from 'aws-cdk-lib';
import * as sfn from 'aws-cdk-lib/aws-stepfunctions';
import * as tasks from 'aws-cdk-lib/aws-stepfunctions-tasks';
const processOrder = new tasks.LambdaInvoke(this, 'ProcessOrder', {
lambdaFunction: orderFunction,
outputPath: '$.Payload'
});
const validateInventory = new tasks.LambdaInvoke(this, 'ValidateInventory', {
lambdaFunction: inventoryFunction,
outputPath: '$.Payload'
});
const chargePayment = new tasks.LambdaInvoke(this, 'ChargePayment', {
lambdaFunction: paymentFunction,
outputPath: '$.Payload'
});
// Standard workflow for order processing
const standardWorkflow = new sfn.StateMachine(this, 'OrderProcessing', {
stateMachineType: sfn.StateMachineType.STANDARD,
definition: processOrder
.next(validateInventory)
.next(chargePayment),
timeout: cdk.Duration.days(7)
});
Standard workflow characteristics:
- Maximum duration: 1 year (useful for multi-day approval processes)
- Exactly-once execution guarantee
- Full execution history stored for 90 days
- Pricing: $0.025 per 1,000 state transitions
- Complete visibility in Step Functions console
- Supports
.syncand.waitForTaskTokenintegration patterns
Express Workflows
Express workflows optimize for high-throughput, short-duration processing:
const receiveIoTEvent = new tasks.LambdaInvoke(this, 'ReceiveEvent', {
lambdaFunction: receiveFunction,
outputPath: '$.Payload'
});
const validateData = new tasks.LambdaInvoke(this, 'ValidateData', {
lambdaFunction: validateFunction,
outputPath: '$.Payload'
});
const storeData = new tasks.DynamoPutItem(this, 'StoreData', {
table: dataTable,
item: {
id: tasks.DynamoAttributeValue.fromString(
sfn.JsonPath.stringAt('$.eventId')
),
timestamp: tasks.DynamoAttributeValue.fromNumber(
sfn.JsonPath.numberAt('$.timestamp')
)
}
});
// Express workflow for IoT processing
const expressWorkflow = new sfn.StateMachine(this, 'IoTProcessing', {
stateMachineType: sfn.StateMachineType.EXPRESS,
definition: receiveIoTEvent
.next(validateData)
.next(storeData),
timeout: cdk.Duration.minutes(5)
});
Express workflow characteristics:
- Maximum duration: 5 minutes
- At-least-once execution (can run multiple times)
- Limited execution history (CloudWatch Logs only)
- Pricing: $1.00 per 1M requests + $0.00001667 per GB-second
- Throughput: 100,000+ executions per second
- Two modes: Synchronous (wait for result) and Asynchronous (fire-and-forget)
Cost Comparison
The pricing difference becomes significant at scale:
// Scenario: 10 million executions/month
// Each workflow: 10 state transitions
// Average duration: 2 seconds
// Memory: 512MB
// Standard Workflow:
// Total transitions: 10M * 10 = 100M
// Cost = (100,000,000 / 1,000) * $0.025 = $2,500/month
// Express Workflow:
// Request cost: (10,000,000 / 1,000,000) * $1.00 = $10
// GB-seconds: (512/1024) * 2 * 10,000,000 = 10,000,000
// Duration cost: 10,000,000 * $0.00001667 = $167
// Total: $177/month
// Savings: $2,323/month (93% reduction)
For high-volume, short-duration workflows, Express workflows deliver significant cost savings. Standard workflows make sense for long-running processes, exactly-once requirements, or audit trail needs.
Amazon States Language Patterns
Step Functions workflows are defined using Amazon States Language (ASL), a JSON-based specification. Understanding data flow control is essential for building maintainable workflows.
Data Flow Control
ASL provides several mechanisms to control how data flows through workflow states:
// Choice state with conditional routing
const decisionTree = new sfn.Choice(this, 'RouteByWorkload', {
stateName: 'Workload Router'
})
.when(
sfn.Condition.numberGreaterThan('$.itemCount', 1000),
largeBatchProcessing
)
.when(
sfn.Condition.numberGreaterThan('$.itemCount', 100),
mediumBatchProcessing
)
.otherwise(smallBatchProcessing);
// Map state for parallel processing
const processItems = new sfn.Map(this, 'ProcessEachItem', {
maxConcurrency: 10,
itemsPath: '$.items',
parameters: {
'item.$': '$$.Map.Item.Value',
'index.$': '$$.Map.Item.Index',
'executionId.$': '$$.Execution.Id'
}
}).itemProcessor(
new sfn.Pass(this, 'ProcessItem')
);
// Parallel state for simultaneous execution
const parallelProcessing = new sfn.Parallel(this, 'FanOut', {
resultPath: '$.parallelResults'
})
.branch(processPayment)
.branch(updateInventory)
.branch(sendNotification);
// Wait state with dynamic timestamp
const waitForSchedule = new sfn.Wait(this, 'WaitUntilScheduled', {
time: sfn.WaitTime.timestampPath('$.scheduledTime')
});
ResultPath and Data Transformation
The ResultPath parameter controls where task output is placed in the state output:
// Task with ResultSelector to filter output
const transformedTask = new tasks.LambdaInvoke(this, 'GetUserData', {
lambdaFunction: getUserFunction,
resultSelector: {
'userId.$': '$.Payload.id',
'userName.$': '$.Payload.name',
'email.$': '$.Payload.email'
},
resultPath: '$.user'
});
// Input: { orderId: '123', customerId: '456' }
// Lambda returns: { id: 'u789', name: 'John', email: '[email protected]', internalData: {...} }
// ResultSelector filters to: { userId: 'u789', userName: 'John', email: '[email protected]' }
// ResultPath places at: { orderId: '123', customerId: '456', user: { userId: 'u789', ... } }
Context Object Variables
ASL provides context variables for accessing execution metadata:
const taskWithContext = new tasks.LambdaInvoke(this, 'ProcessWithContext', {
lambdaFunction: processFunction,
payload: sfn.TaskInput.fromObject({
'data.$': '$.inputData',
'executionId.$': '$$.Execution.Id',
'executionName.$': '$$.Execution.Name',
'stateMachineId.$': '$$.StateMachine.Id',
'timestamp.$': '$$.State.EnteredTime'
})
});
Error Handling and Retry Strategies
Production workflows require comprehensive error handling. Step Functions provides built-in retry and catch mechanisms.
Exponential Backoff with Retry
const resilientTask = new tasks.LambdaInvoke(this, 'ProcessPayment', {
lambdaFunction: paymentFunction,
payloadResponseOnly: true,
retryOnServiceExceptions: true
})
.addRetry({
// Fast retries for transient errors
errors: ['States.TaskFailed', 'ThrottlingException', 'ServiceUnavailable'],
interval: cdk.Duration.seconds(2),
maxAttempts: 3,
backoffRate: 2.0 // 2s, 4s, 8s
})
.addRetry({
// Different strategy for timeout errors
errors: ['States.Timeout'],
interval: cdk.Duration.seconds(5),
maxAttempts: 2,
backoffRate: 1.5 // 5s, 7.5s
});
The retry mechanism handles transient failures without additional code. The backoffRate parameter controls exponential backoff: a rate of 2.0 doubles the wait time after each retry.
Note: payloadResponseOnly: true simplifies the response structure by returning only the Lambda payload instead of the full Step Functions wrapper. However, this means you lose access to metadata like StatusCode and ExecutedVersion in the workflow state. Use outputPath: '$.Payload' instead if you need this metadata for debugging or auditing.
Error Catching and Compensation
const handlePaymentFailure = new tasks.SqsSendMessage(this, 'NotifyFailure', {
queue: failureQueue,
messageBody: sfn.TaskInput.fromObject({
'orderId.$': '$.orderId',
'error.$': '$.errorInfo.Error',
'cause.$': '$.errorInfo.Cause'
})
});
const notifyAdmin = new tasks.SnsPublish(this, 'AlertAdmin', {
topic: adminTopic,
message: sfn.TaskInput.fromText('Critical payment processing failure')
});
const paymentTask = new tasks.LambdaInvoke(this, 'ChargeCustomer', {
lambdaFunction: paymentFunction,
payloadResponseOnly: true
})
.addCatch(handlePaymentFailure, {
// Handle specific business errors
errors: ['PaymentDeclined', 'InsufficientFunds'],
resultPath: '$.errorInfo'
})
.addCatch(notifyAdmin, {
// Catch all other errors
errors: ['States.ALL'],
resultPath: '$.error'
});
The resultPath in catch blocks preserves the original input while adding error information. This allows downstream states to access both the input data and error details.
Lambda Error Handling
Lambda functions should throw specific error types for Step Functions to catch:
export const handler = async (event: { orderId: string, amount: number }) => {
try {
const result = await processPayment(event.amount, event.orderId);
return result;
} catch (error: any) {
// Throw specific error types for Step Functions to catch
if (error.code === 'INSUFFICIENT_FUNDS') {
throw new Error('InsufficientFunds');
}
if (error.code === 'CARD_DECLINED') {
throw new Error('PaymentDeclined');
}
// Re-throw for generic retry logic
throw error;
}
};
Circuit Breaker Pattern
For systems with multiple fallback options:
// Declare the fallbacks first: every addCatch target must already exist
const logFailure = new tasks.DynamoPutItem(this, 'LogFailure', {
table: failureTable,
item: {
id: tasks.DynamoAttributeValue.fromString(
sfn.JsonPath.stringAt('$.requestId')
),
// $$.State.EnteredTime is an ISO-8601 string, not an epoch number
timestamp: tasks.DynamoAttributeValue.fromString(
sfn.JsonPath.stringAt('$$.State.EnteredTime')
)
}
});
const fallbackApi = new tasks.LambdaInvoke(this, 'FallbackAPI', {
lambdaFunction: fallbackApiFunction,
payloadResponseOnly: true
})
.addCatch(logFailure, {
errors: ['States.ALL'],
resultPath: '$.fallbackError'
});
const primaryApi = new tasks.LambdaInvoke(this, 'PrimaryAPI', {
lambdaFunction: primaryApiFunction,
payloadResponseOnly: true
})
.addCatch(fallbackApi, {
errors: ['States.ALL'],
resultPath: '$.primaryError'
});
This pattern provides automatic fallback with error context preserved at each level.
Distributed Map for Large-Scale Processing
Regular Map states support up to 40 concurrent iterations. Distributed Map removes this limitation for processing millions of items.
Basic Distributed Map
import * as s3 from 'aws-cdk-lib/aws-s3';
const inputBucket = s3.Bucket.fromBucketName(this, 'InputBucket', 'data-input');
const resultsBucket = s3.Bucket.fromBucketName(this, 'ResultsBucket', 'data-results');
const processRecord = new tasks.LambdaInvoke(this, 'ProcessRecord', {
lambdaFunction: processFunction,
payloadResponseOnly: true
});
const distributedMap = new sfn.DistributedMap(this, 'ProcessLargeDataset', {
maxConcurrency: 10000,
itemReader: new sfn.S3JsonItemReader({
bucket: inputBucket,
key: 'input-data/*.json'
}),
resultWriter: new sfn.ResultWriter({
bucket: resultsBucket,
prefix: 'processing-results/'
}),
toleratedFailurePercentage: 5
});
distributedMap.itemProcessor(processRecord);
The toleratedFailurePercentage parameter allows continuing processing even when some items fail. This is useful for batch processing where partial failures are acceptable.
CSV and JSONL Processing
Distributed Map supports multiple input formats:
// Process CSV files
const csvProcessing = new sfn.DistributedMap(this, 'ProcessCSV', {
itemReader: new sfn.S3CsvItemReader({
bucket: csvBucket,
key: 'data/*.csv',
csvHeaders: sfn.CsvHeaders.use(['id', 'name', 'value', 'timestamp'])
})
});
// Process JSON Lines files
const jsonlProcessing = new sfn.DistributedMap(this, 'ProcessJSONL', {
itemReader: new sfn.S3JsonItemReader({
bucket: logsBucket,
key: 'application-logs/*.jsonl'
})
});
Concurrency and Cost at Scale
Distributed Map trades money for wall-clock time, and both sides of that trade are predictable before you run it:
// Scenario: 10 million sensor readings across 12,000 JSON files in S3
const processReadings = new sfn.DistributedMap(this, 'ProcessSensorData', {
maxConcurrency: 8000,
itemReader: new sfn.S3JsonItemReader({
bucket: sensorBucket,
key: 'readings/*.json'
}),
resultWriter: new sfn.ResultWriter({
bucket: resultsBucket,
prefix: 'processed/'
})
});
// Wall-clock time tracks maxConcurrency, not item count:
// 8,000 child executions run at once instead of one sequential loop.
// Billing still tracks item count: 10M state transitions at
// $0.025 per 1,000 is about $250, before Lambda and S3 costs.
maxConcurrency sets how many child executions run at once, up to a ceiling of 10,000. Duration therefore scales with the number of batches, while the bill scales with the number of items either way. When cost matters more than latency, batch inside the item processor and lower the concurrency.
Callback Pattern with Task Tokens
Task Tokens let a workflow pause while it waits for an external event, which is what makes human approvals and other long-running steps possible.
Human Approval Workflow
import * as sqs from 'aws-cdk-lib/aws-sqs';
const approvalQueue = new sqs.Queue(this, 'ApprovalQueue');
const requestApproval = new tasks.SqsSendMessage(this, 'SendApprovalRequest', {
queue: approvalQueue,
messageBody: sfn.TaskInput.fromObject({
'orderId.$': '$.orderId',
'amount.$': '$.amount',
'customerId.$': '$.customerId',
'taskToken': sfn.JsonPath.taskToken // Critical: Task token for callback
}),
integrationPattern: sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN,
timeout: cdk.Duration.hours(24)
});
const handleTimeout = new tasks.SnsPublish(this, 'NotifyTimeout', {
topic: timeoutTopic,
message: sfn.TaskInput.fromText('Approval request timed out')
});
const approvalTask = requestApproval
.addCatch(handleTimeout, {
errors: ['States.Timeout'],
resultPath: '$.timeoutError'
});
The workflow pauses at this state until SendTaskSuccess or SendTaskFailure is called with the task token.
Processing Approval Requests
import { SQSEvent } from 'aws-lambda';
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
const dynamodb = new DynamoDBClient({});
export const approvalHandler = async (event: SQSEvent) => {
for (const record of event.Records) {
const { orderId, amount, customerId, taskToken } = JSON.parse(record.body);
// Store approval request for admin UI
await dynamodb.send(new PutItemCommand({
TableName: process.env.APPROVALS_TABLE!,
Item: {
orderId: { S: orderId },
amount: { N: amount.toString() },
customerId: { S: customerId },
taskToken: { S: taskToken },
status: { S: 'PENDING' },
timestamp: { N: Date.now().toString() }
}
}));
}
};
Approval Decision Callback
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { SFNClient, SendTaskSuccessCommand, SendTaskFailureCommand } from '@aws-sdk/client-sfn';
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
const sfn = new SFNClient({});
const dynamodb = new DynamoDBClient({});
export const approveHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
const { orderId, decision } = JSON.parse(event.body || '{}');
// Retrieve approval request
const result = await dynamodb.send(new GetItemCommand({
TableName: process.env.APPROVALS_TABLE!,
Key: { orderId: { S: orderId } }
}));
if (!result.Item) {
return { statusCode: 404, body: 'Approval request not found' };
}
const taskToken = result.Item.taskToken.S!;
if (decision === 'approved') {
await sfn.send(new SendTaskSuccessCommand({
taskToken,
output: JSON.stringify({ approved: true, orderId })
}));
} else {
await sfn.send(new SendTaskFailureCommand({
taskToken,
error: 'ApprovalRejected',
cause: 'Order rejected by admin'
}));
}
return { statusCode: 200, body: 'Decision recorded' };
};
The workflow continues execution when the callback is invoked. This pattern works with any external system that can store and retrieve task tokens.
Direct Service Integrations
Step Functions integrates with over 220 AWS services directly, eliminating the need for Lambda wrappers.
DynamoDB Integration
const saveOrder = new tasks.DynamoPutItem(this, 'SaveOrder', {
table: ordersTable,
item: {
orderId: tasks.DynamoAttributeValue.fromString(
sfn.JsonPath.stringAt('$.orderId')
),
customerId: tasks.DynamoAttributeValue.fromString(
sfn.JsonPath.stringAt('$.customerId')
),
amount: tasks.DynamoAttributeValue.fromNumber(
sfn.JsonPath.numberAt('$.amount')
),
timestamp: tasks.DynamoAttributeValue.fromString(
sfn.JsonPath.stringAt('$$.State.EnteredTime')
),
status: tasks.DynamoAttributeValue.fromString('PROCESSING')
}
});
This approach saves Lambda invocation costs and reduces latency.
SNS and SQS Integration
const notifyUser = new tasks.SnsPublish(this, 'NotifyUser', {
topic: notificationTopic,
message: sfn.TaskInput.fromObject({
'orderId.$': '$.orderId',
'status': 'processing',
'timestamp.$': '$$.State.EnteredTime'
})
});
const queueWork = new tasks.SqsSendMessage(this, 'QueueWork', {
queue: workQueue,
messageBody: sfn.TaskInput.fromJsonPathAt('$.workload'),
messageDeduplicationId: sfn.JsonPath.stringAt('$.orderId')
});
ECS Task with Sync
For long-running batch jobs:
import * as ecs from 'aws-cdk-lib/aws-ecs';
const runBatchJob = new tasks.EcsRunTask(this, 'RunDataProcessing', {
cluster: ecsCluster,
taskDefinition: batchJobTask,
launchTarget: new tasks.EcsFargateLaunchTarget(),
integrationPattern: sfn.IntegrationPattern.RUN_JOB, // .sync pattern
containerOverrides: [{
containerDefinition: batchContainer,
environment: [
{ name: 'INPUT_BUCKET', value: 'data-input' },
{ name: 'OUTPUT_BUCKET', value: 'data-output' }
]
}]
});
The workflow waits until the ECS task completes, which can take hours. This is useful for data processing, ML training, or other batch operations.
SDK Service Integration
For services without dedicated CDK constructs:
const updateSecret = new tasks.CallAwsService(this, 'UpdateSecret', {
service: 'secretsmanager',
action: 'updateSecret',
parameters: {
SecretId: 'MyApplicationSecret',
SecretString: sfn.JsonPath.stringAt('$.newSecretValue')
},
iamResources: ['arn:aws:secretsmanager:*:*:secret:MyApplicationSecret-*']
});
The CallAwsService construct enables calling any AWS SDK API directly from Step Functions.
Production Implementation Pattern
Here’s a complete workflow implementation with logging, monitoring, and error handling:
import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import * as sfn from 'aws-cdk-lib/aws-stepfunctions';
import * as tasks from 'aws-cdk-lib/aws-stepfunctions-tasks';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as cloudwatch_actions from 'aws-cdk-lib/aws-cloudwatch-actions';
export class OrderProcessingWorkflow extends Construct {
public readonly stateMachine: sfn.StateMachine;
constructor(scope: Construct, id: string) {
super(scope, id);
// Lambda functions
const validateOrder = new lambda.Function(this, 'ValidateOrder', {
runtime: lambda.Runtime.NODEJS_24_X,
handler: 'validate.handler',
code: lambda.Code.fromAsset('lambda'),
timeout: cdk.Duration.seconds(30)
});
const processPayment = new lambda.Function(this, 'ProcessPayment', {
runtime: lambda.Runtime.NODEJS_24_X,
handler: 'payment.handler',
code: lambda.Code.fromAsset('lambda'),
timeout: cdk.Duration.seconds(60)
});
// Task definitions
const validateTask = new tasks.LambdaInvoke(this, 'ValidateOrderTask', {
lambdaFunction: validateOrder,
outputPath: '$.Payload'
});
// Declared before paymentTask: an addCatch target must already exist
const handlePaymentFailure = new sfn.Fail(this, 'PaymentFailed', {
error: 'PaymentProcessingFailed',
cause: 'Unable to process payment after retries'
});
const paymentTask = new tasks.LambdaInvoke(this, 'ProcessPaymentTask', {
lambdaFunction: processPayment,
outputPath: '$.Payload',
retryOnServiceExceptions: true
})
.addRetry({
errors: ['ThrottlingException'],
interval: cdk.Duration.seconds(1),
maxAttempts: 3,
backoffRate: 2
})
.addCatch(handlePaymentFailure, {
errors: ['PaymentFailed'],
resultPath: '$.error'
});
const notifyInvalidOrder = new sfn.Fail(this, 'InvalidOrder', {
error: 'OrderValidationFailed',
cause: 'Order validation did not pass'
});
// Workflow definition
const definition = validateTask
.next(new sfn.Choice(this, 'OrderValid?')
.when(sfn.Condition.booleanEquals('$.valid', true), paymentTask)
.otherwise(notifyInvalidOrder)
);
// CloudWatch log group
const logGroup = new logs.LogGroup(this, 'Logs', {
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY
});
// State machine with logging and tracing
this.stateMachine = new sfn.StateMachine(this, 'OrderProcessing', {
definition,
stateMachineType: sfn.StateMachineType.EXPRESS,
logs: {
destination: logGroup,
level: sfn.LogLevel.ERROR,
includeExecutionData: true
},
tracingEnabled: true // Enable X-Ray
});
// CloudWatch alarms
const alertTopic = new sns.Topic(this, 'AlertTopic');
const failureAlarm = new cloudwatch.Alarm(this, 'HighFailureRate', {
metric: this.stateMachine.metricFailed({
period: cdk.Duration.minutes(5)
}),
threshold: 10,
evaluationPeriods: 2,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING
});
failureAlarm.addAlarmAction(new cloudwatch_actions.SnsAction(alertTopic));
}
}
This implementation includes error handling, logging, X-Ray tracing, and CloudWatch alarms for production monitoring.
Cost Optimization Strategies
Understanding Step Functions pricing enables significant cost reductions.
Minimize State Transitions
// Anti-pattern: Unnecessary Pass states
const inefficient = sfn.Chain.start(task1)
.next(new sfn.Pass(this, 'PassData1', {})) // Unnecessary
.next(task2)
.next(new sfn.Pass(this, 'PassData2', {})) // Unnecessary
.next(task3);
// Cost: 5 state transitions
// Optimized: Remove unnecessary states
const efficient = sfn.Chain.start(task1)
.next(task2)
.next(task3);
// Cost: 3 state transitions (40% reduction)
Batch Processing
Processing items individually creates many state transitions. Batching reduces costs:
// Process 10,000 items in 100 batches of 100
const batchProcessor = new sfn.Map(this, 'ProcessBatches', {
itemsPath: '$.batches',
maxConcurrency: 10
}).itemProcessor(
new tasks.LambdaInvoke(this, 'ProcessBatch', {
lambdaFunction: batchFunction,
payload: sfn.TaskInput.fromObject({
'items.$': '$$.Map.Item.Value',
'batchId.$': '$$.Map.Item.Index'
})
})
);
// Individual processing: 10,000 state transitions
// Batch processing: 100 state transitions
// Savings: 99% reduction
Direct Service Integrations
Using direct integrations eliminates Lambda costs:
// Original: Lambda wrapper for DynamoDB
const withLambda = new tasks.LambdaInvoke(this, 'SaveOrder', {
lambdaFunction: dynamoWrapperFunction
});
// Cost: Lambda invocation + state transition
// Optimized: Direct DynamoDB integration
const directIntegration = new tasks.DynamoPutItem(this, 'SaveOrder', {
table: ordersTable,
item: { /* ... */ }
});
// Cost: State transition only (no Lambda cost)
Monitoring and Observability
Production workflows require comprehensive monitoring.
CloudWatch Metrics
const workflow = new sfn.StateMachine(this, 'ProductionWorkflow', {
definition,
tracingEnabled: true
});
// Failure rate alarm
const failureAlarm = new cloudwatch.Alarm(this, 'FailureRate', {
metric: workflow.metricFailed({
period: cdk.Duration.minutes(5),
statistic: cloudwatch.Stats.SUM
}),
threshold: 10,
evaluationPeriods: 2
});
// Duration alarm (p99)
const durationAlarm = new cloudwatch.Alarm(this, 'LongExecution', {
metric: workflow.metricDuration({
statistic: cloudwatch.Stats.PERCENTILE_99
}),
threshold: cdk.Duration.minutes(10).toMilliseconds(),
evaluationPeriods: 1
});
// Throttling alarm
const throttleAlarm = new cloudwatch.Alarm(this, 'Throttling', {
metric: workflow.metricThrottled({
period: cdk.Duration.minutes(1)
}),
threshold: 1,
evaluationPeriods: 1
});
CloudWatch Dashboard
const dashboard = new cloudwatch.Dashboard(this, 'WorkflowDashboard', {
dashboardName: 'step-functions-monitoring'
});
dashboard.addWidgets(
new cloudwatch.GraphWidget({
title: 'Execution Rate',
left: [
workflow.metricStarted({ label: 'Started' }),
workflow.metricSucceeded({ label: 'Succeeded' }),
workflow.metricFailed({ label: 'Failed' })
],
width: 12
}),
new cloudwatch.GraphWidget({
title: 'Execution Duration (ms)',
left: [
workflow.metricDuration({
statistic: cloudwatch.Stats.AVERAGE,
label: 'Average'
}),
workflow.metricDuration({
statistic: cloudwatch.Stats.PERCENTILE_99,
label: 'p99'
})
],
width: 12
})
);
X-Ray Tracing
Enable X-Ray to visualize service dependencies and identify bottlenecks:
X-Ray shows execution time for each service call, making it easy to identify slow components.
When to Use Alternatives
Step Functions isn’t always the right choice. Here’s when to consider alternatives:
Temporal
Use Temporal when:
- Code-as-workflow is preferred over JSON/CDK definitions
- Multi-cloud deployment is required
- Complex business logic lives in workflows
- Sub-second latency is critical
- Local development without mocks is important
// Temporal workflow (TypeScript SDK)
export async function orderProcessingWorkflow(order: Order): Promise<void> {
await activities.validateOrder(order);
try {
await activities.processPayment(order);
} catch (err) {
await activities.refundPayment(order);
throw err;
}
await activities.shipOrder(order);
}
// Workflow logic is TypeScript, versioned with application code
// Better IDE support, debugging, and testing
AWS MWAA (Managed Airflow)
Use Airflow when:
- Data pipeline orchestration (ETL, batch processing)
- Complex dependencies between scheduled jobs
- Rich UI for data engineers is essential
- Integration with data tools (Spark, Hive, Presto)
MWAA bills a continuously running environment; Step Functions bills per execution. For bursty, event-driven workloads that difference decides the comparison, whatever the hourly rate happens to be.
EventBridge Pipes
Use for simple event transformations and routing without complex logic:
const pipe = new pipes.Pipe(this, 'SimpleProcessing', {
source: new pipes.SqsSource(queue),
target: new pipes.LambdaTarget(processFunction),
enrichment: new pipes.LambdaEnrichment(transformFunction),
filter: pipes.Filter.fromObject({
body: {
amount: [{ numeric: ['>', 100] }]
}
})
});
Simpler for linear pipelines without branching or complex error handling.
Common Pitfalls
Express workflows guarantee at-least-once execution, so any task with a side effect can run twice. The state machine will not deduplicate for you. Write an idempotency key into the payload at the entry state and check it inside the handler before charging a card or shipping an order.
Task tokens do not expire on their own. A WAIT_FOR_TASK_TOKEN task with no timeout holds the execution until the state machine’s own limit, which is a year on Standard workflows. Set a task-level timeout and catch States.Timeout, as the approval example does.
Leaving resultPath unset replaces the state input with the task result. Downstream states then lose the order ID, the correlation ID, and everything else the workflow was carrying. Name a resultPath on every task whose input later states still need.
Choosing a Default
Express is the right default for event-driven work that finishes in minutes. It is priced per request and per GB-second while Standard is priced per state transition, and the worked example above shows how far apart those two models drift once volume climbs. Override that default in three cases. Long-running processes that wait on a human or an external system need Standard’s one-year ceiling and the callback pattern. Anything that must not run twice needs Standard’s exactly-once guarantee, because at-least-once delivery pushes idempotency into your own code. Audit requirements need Standard’s 90-day execution history, since Express keeps only what you send to CloudWatch Logs.
Two smaller defaults follow from the same reasoning. Prefer a direct service integration over a Lambda wrapper unless the step needs logic that a CallAwsService call cannot express. Reach for Distributed Map once a dataset outgrows the 40 concurrent iterations of a regular Map state, and batch items before iterating when state transitions dominate the bill.
References
- What is AWS Step Functions? - Official developer guide introducing state machines, workflow types, and core orchestration concepts
- Choosing a Workflow Type (Standard vs Express) - Decision guide comparing exactly-once Standard Workflows with at-least-once Express Workflows for high-volume use cases
- Amazon States Language (ASL) Structure - Formal specification of the JSON-based language used to define Step Functions state machines
- Workflow States Reference - Documentation for all state types: Task, Choice, Parallel, Map, Wait, Pass, Succeed, Fail
- AWS Step Functions Service Integrations - Supported AWS service integrations and the three SDK integration patterns: request-response, sync, and callback
- AWS Step Functions Pricing - State transition pricing model for Standard and Express Workflows with worked cost examples
Related posts
AppSync subscriptions fire only on mutations. This explores bridging downstream BFF events into a NONE-data-source mutation with EventBridge and CDK.
Building scalable real-time APIs with AWS AppSync: JavaScript resolvers, subscription filtering, caching strategies, and infrastructure as code patterns.
Implement secure cross-account event distribution with Amazon SNS and SQS: IAM policies, KMS encryption, AWS CDK, and common production pitfalls.
Implement the Saga pattern across microservices with AWS Step Functions and EventBridge, covering idempotency, compensation logic, and production patterns.
Before building an internal service layer, decide whether you need one: what it costs per call, the volume where VPC Lattice wins, and when direct invoke still beats it.