Step Functions vs EventBridge for Serverless Workflow Orchestration

Choosing Between Two Orchestration Models

For reference, Step Functions and EventBridge solve overlapping but distinct problems. Step Functions orchestrates workflows where you need to coordinate multiple steps with branching logic, retries, and state management. EventBridge routes events between loosely coupled services where each service reacts independently.

Here's my quick decision rule: if you need a flow chart (step A → decision → step B or C → wait → step D), use Step Functions. If you need a pub/sub pattern where multiple services respond to the same event independently, use EventBridge.

Step Functions: Express vs Standard

Standard workflows are durable. They can run for up to a year, and AWS persists state between steps. If a step fails, you can inspect the execution history and restart from the failure point. You pay per state transition ($0.025 per 1,000 transitions).

Express workflows are ephemeral. They run for up to 5 minutes, don't persist state, and you can't inspect or restart them after failure. But they're drastically cheaper for high-volume workloads — $0.000001 per request plus duration charges. For a workflow processing 10 million events per month, Express saves thousands of dollars compared to Standard.

# Standard workflow for order processing
{
  "StartAt": "ValidateOrder",
  "States": {
    "ValidateOrder": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123:function:validate-order",
      "Next": "CheckInventory",
      "Retry": [{"ErrorEquals": ["States.TaskFailed"], "MaxAttempts": 2}]
    },
    "CheckInventory": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123:function:check-inventory",
      "Next": "InventoryDecision"
    },
    "InventoryDecision": {
      "Type": "Choice",
      "Choices": [{
        "Variable": "$.inStock",
        "BooleanEquals": true,
        "Next": "ProcessPayment"
      }],
      "Default": "BackorderNotification"
    },
    "ProcessPayment": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123:function:process-payment",
      "Next": "ShipOrder"
    },
    "ShipOrder": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123:function:ship-order",
      "End": true
    },
    "BackorderNotification": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123:function:notify-backorder",
      "End": true
    }
  }
}

EventBridge for Event-Driven Architecture

Here is where things get interesting -- EventBridge shines when services don't need to know about each other. The order service publishes an "OrderPlaced" event. The inventory service, the notification service, and the analytics service each have their own rules that match that event. They react independently. If the analytics service is down, the order and notification flows aren't affected.

# EventBridge rule matching order events
resource "aws_cloudwatch_event_rule" "order_placed" {
  name           = "order-placed"
  event_bus_name = aws_cloudwatch_event_bus.orders.name
  event_pattern = jsonencode({
    source      = ["com.myapp.orders"]
    detail-type = ["OrderPlaced"]
    detail = {
      total = [{"numeric": [">", 100]}]
    }
  })
}

# Multiple targets from one rule
resource "aws_cloudwatch_event_target" "inventory" {
  rule           = aws_cloudwatch_event_rule.order_placed.name
  event_bus_name = aws_cloudwatch_event_bus.orders.name
  arn            = aws_lambda_function.update_inventory.arn
  target_id      = "inventory"
}

resource "aws_cloudwatch_event_target" "analytics" {
  rule           = aws_cloudwatch_event_rule.order_placed.name
  event_bus_name = aws_cloudwatch_event_bus.orders.name
  arn            = aws_sqs_queue.analytics_queue.arn
  target_id      = "analytics"
}

When to Combine Both

The strongest pattern uses both together. EventBridge handles event routing between bounded contexts. Step Functions orchestrates complex workflows within a single bounded context.

Example: EventBridge receives a "CustomerSignup" event and routes it to three independent targets. One of those targets starts a Step Functions workflow that runs the multi-step onboarding process — create account, send welcome email, provision resources, schedule follow-up. The onboarding workflow's internal steps are coordinated by Step Functions, but the trigger and the decoupling from other services happens through EventBridge.

Error Handling Differences

For reference, Step Functions gives you fine-grained retry and catch mechanisms at each step. You can retry with exponential backoff, catch specific error types, and route to error-handling states. This is built into the state machine definition.

Looking closer, EventBridge has DLQs (dead-letter queues) for failed target invocations. If a target Lambda function fails, the event goes to the DLQ for later processing. But you don't get the step-by-step retry logic that Step Functions provides. For EventBridge, your consumers need their own retry logic.

In practice, this means Step Functions is better for workflows where partial failure needs careful handling (financial transactions, order processing), while EventBridge is better for fire-and-forget scenarios where individual consumer failures are isolated.

Step Functions Callbacks and Wait States

One of Step Functions' most useful features is the ability to pause a workflow and wait for an external callback. You send a task token to an external system, and the workflow pauses until that system calls back with SendTaskSuccess or SendTaskFailure.

"WaitForApproval": {
  "Type": "Task",
  "Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
  "Parameters": {
    "FunctionName": "send-approval-request",
    "Payload": {
      "taskToken.$": "$$.Task.Token",
      "requestDetails.$": "$.request"
    }
  },
  "TimeoutSeconds": 86400,
  "Next": "ProcessApproval"
}

Set a timeout on callback states. Without one, the workflow waits indefinitely for a response that might never come.

EventBridge Pipes: The Connector Pattern

On the practical side, EventBridge Pipes connect sources to targets with optional filtering, enrichment, and transformation. They support SQS queues, DynamoDB Streams, Kinesis streams, and Kafka topics as sources, not just EventBridge events.

resource "aws_pipes_pipe" "dynamo_to_step_functions" {
  name     = "orders-to-fulfillment"
  role_arn = aws_iam_role.pipe.arn
  source   = aws_dynamodb_table.orders.stream_arn
  source_parameters {
    dynamodb_stream_parameters {
      starting_position = "LATEST"
      batch_size        = 1
    }
  }
  target = aws_sfn_state_machine.fulfillment.arn
}

Cost Comparison at Scale

For a workflow processing 1 million orders per month with 8 state transitions each:

  • Step Functions Standard: 8M transitions at $0.025/1000 = $200/month
  • Step Functions Express: roughly $10/month
  • EventBridge Rules: 3M target invocations at $1.00/million = $3/month

The cost difference is dramatic. But they're solving different problems, so comparing on cost alone misses the point.

Monitoring and Debugging

On the practical side, Step Functions has excellent built-in observability. The console shows a visual representation of each execution, highlighting which step succeeded and which failed. For production debugging, this visual trace is invaluable.

EventBridge is harder to debug. An event that doesn't match any rule disappears silently. Enable CloudWatch metrics for your event bus to track unmatched events. Create a catch-all rule that sends unmatched events to CloudWatch Logs during development.

Retry and Idempotency Patterns

Step Functions retries are configurable per state with exponential backoff and jitter. But retries mean your downstream service receives the same request multiple times. If your Lambda function isn't idempotent, retries can cause duplicate side effects — charging a customer twice, sending duplicate notifications, inserting duplicate records.

Design every Lambda function behind a Step Functions task for idempotency. The simplest approach: generate a unique execution ID at the start of the workflow and pass it through every step. Each step uses this ID to check whether it's already completed its work. If it has, it returns the previous result without repeating the side effect.

def handler(event, context):
    execution_id = event['executionId']
    order_id = event['orderId']
    idempotency_key = f"{execution_id}-{order_id}-charge"

    existing = get_from_dynamodb(idempotency_key)
    if existing:
        return existing['result']

    result = charge_customer(order_id, event['amount'])
    store_in_dynamodb(idempotency_key, {'result': result}, ttl=86400)
    return result

For EventBridge consumers, idempotency is equally important. EventBridge guarantees at-least-once delivery, meaning your consumer might receive the same event more than once. Use the event ID (available in the detail-type) as a deduplication key. SQS FIFO queues with content-based deduplication are another option when exact-once processing is required.

Choosing the Right Concurrency Model

Step Functions supports two concurrency patterns. The Map state processes items in parallel up to a configurable maximum concurrency. If you have 1000 items and set max concurrency to 50, Step Functions processes 50 items at a time. This is useful for batch operations where you need to respect rate limits on downstream services.

EventBridge doesn't have built-in concurrency control. If an event matches a rule with a Lambda target, EventBridge invokes the Lambda. If 10,000 events arrive in a second, EventBridge tries to invoke 10,000 Lambda executions. You need Lambda's reserved concurrency or an SQS queue with a batch window between EventBridge and Lambda to control throughput.

For bursty workloads where you need controlled parallelism, Step Functions Map state gives you the knob. For steady-state event processing with occasional bursts, EventBridge with SQS buffering gives better cost efficiency because you avoid the Step Functions per-transition charge while still controlling concurrency through SQS batch size and Lambda concurrent execution limits.