xstate-statemachine vs AWS Step Functions

ASL next to XState JSON for the same order workflow; event-driven transitions, timers, retries, the local-testing story, and when Step Functions is still the right call.

AWS Step Functions is a managed workflow service. You describe a state machine in the Amazon States Language (ASL), and AWS runs it, persists it, retries it and draws it. xstate-statemachine is a library. You describe a statechart in XState JSON and run it in your own Python process, with state in your own store. They overlap on “a JSON state machine that survives failures”, and they differ on almost everything else.

This table is generated from docs/_data/comparisons.json (workflow_rows). Corrections are welcome as PRs against that file. Every row carries a source note. It was checked against the AWS Step Functions developer guide, read 2026-10-08; see the AWS Step Functions documentation for the current state.

Feature table

Capability xstate-statemachine AWS Step Functions Source
Definition language XState JSON (statecharts): states, events, guards, actions, after, invoke Amazon States Language (ASL) JSON: Task / Choice / Wait / Parallel / Map states AWS Step Functions developer guide, read 2026-10-08: ASL spec (states-language.html)
Where it runs In your Python process, on any host; state in your store (SQLite, Redis, Postgres) Managed AWS service; state held by AWS AWS Step Functions developer guide, read 2026-10-08: welcome.html
Event-driven transitions Any state reacts to any named event (send) at any time Flow is task-to-task; outside input via callback task tokens (.waitForTaskToken) AWS Step Functions developer guide, read 2026-10-08: connect-to-resource.html#connect-wait-token
Hierarchy and parallel Nested compound states and parallel regions with statechart semantics Parallel and Map states; nested workflows via child executions AWS Step Functions developer guide, read 2026-10-08: state-parallel.html, state-map.html, concepts-nested-workflows.html
Timers after per state, cancelled on exit; durable via persisted deadlines + DueTimerScanner Wait state and task TimeoutSeconds / HeartbeatSeconds; durable by the service AWS Step Functions developer guide, read 2026-10-08: state-wait.html, state-task.html (TimeoutSeconds, HeartbeatSeconds)
Retries and error handling onError transitions; RetryPolicy backoff with jitter; CircuitBreaker; dead-letter tag Declarative Retry / Catch per state with backoff and jitter AWS Step Functions developer guide, read 2026-10-08: concepts-error-handling.html
Long-running durability As durable as your store; you run the process and one scheduler role Standard workflows up to one year, fully managed AWS Step Functions developer guide, read 2026-10-08: service-quotas.html (“Maximum execution time: 1 year”)
Local testing Plain pytest: SimulatedClock, both engines, xsm simulate, no emulator TestState API (mocks per state); Step Functions Local is now marked unsupported by AWS AWS Step Functions developer guide, read 2026-10-08: test-state-isolation.html; sfn-local.html (“Step Functions Local is unsupported”)
Visual editor Stately editor round-trip; xsm diagram (Mermaid / PlantUML) Workflow Studio in the AWS console AWS Step Functions developer guide, read 2026-10-08: workflow-studio.html
Observability Plugins: transition log, audit, OTel / Prometheus; you ship them Execution history, CloudWatch, X-Ray, built in AWS Step Functions developer guide, read 2026-10-08: concepts-monitoring.html
Cost model Library: your compute and storage only Per state transition (Standard) or per request and duration (Express) AWS Step Functions developer guide, read 2026-10-08: aws.amazon.com/step-functions/pricing
Lock-in Portable JSON; the same chart runs in XState (TypeScript) and in Python ASL runs only on AWS AWS Step Functions developer guide, read 2026-10-08: ASL is executed only by the AWS service

The same workflow, side by side

An order: charge the card with retries, wait for the warehouse to confirm shipment, and cancel if nothing arrives within 3 days.

Step Functions (ASL). The warehouse wait is a callback task. The workflow hands out a task token and parks until something calls SendTaskSuccess:

{
  "StartAt": "Charge",
  "States": {
    "Charge": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "charge-card",
        "Payload.$": "$"
      },
      "Retry": [
        {
          "ErrorEquals": ["States.TaskFailed"],
          "IntervalSeconds": 2,
          "MaxAttempts": 3,
          "BackoffRate": 2.0,
          "JitterStrategy": "FULL"
        }
      ],
      "Catch": [
        {
          "ErrorEquals": ["States.ALL"],
          "Next": "PaymentFailed"
        }
      ],
      "Next": "AwaitShipment"
    },
    "AwaitShipment": {
      "Type": "Task",
      "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken",
      "Parameters": {
        "QueueUrl": "https://sqs.../warehouse",
        "MessageBody": {
          "order.$": "$.id",
          "token.$": "$$.Task.Token"
        }
      },
      "TimeoutSeconds": 259200,
      "Catch": [
        {
          "ErrorEquals": ["States.Timeout"],
          "Next": "Cancelled"
        }
      ],
      "Next": "Shipped"
    },
    "Shipped": {
      "Type": "Succeed"
    },
    "Cancelled": {
      "Type": "Fail",
      "Error": "NotShipped"
    },
    "PaymentFailed": {
      "Type": "Fail",
      "Error": "PaymentFailed"
    }
  }
}

xstate-statemachine (XState JSON). The wait is a state that listens for an event, with an after deadline. The customer can also cancel while it waits, which in ASL would need a second callback path:

{
  "id": "order",
  "initial": "charging",
  "context": {
    "attempt": 0
  },
  "states": {
    "charging": {
      "invoke": {
        "src": "chargeCard",
        "onDone": "awaitingShipment",
        "onError": {
          "target": "retrying",
          "actions": "retryBump"
        }
      }
    },
    "retrying": {
      "after": {
        "retryDelay": [
          {
            "target": "charging",
            "guard": "retryCanRetry"
          },
          {
            "target": "paymentFailed"
          }
        ]
      }
    },
    "awaitingShipment": {
      "on": {
        "SHIPPED": "shipped",
        "CUSTOMER_CANCELLED": "cancelled"
      },
      "after": {
        "259200000": "cancelled"
      }
    },
    "shipped": {
      "type": "final"
    },
    "cancelled": {
      "type": "final"
    },
    "paymentFailed": {
      "type": "final"
    }
  }
}

The shapes map closely. Retry becomes RetryPolicy plus a retrying state. Catch becomes onError, and a .waitForTaskToken task becomes a state with on events. The difference is in who may move the machine. In ASL, the flow moves from task to task, and outside input enters only through the token you handed out. In a statechart, any state can react to any event, so “cancel while waiting”, “the address changed while charging” and “pause” are transitions, not extra plumbing.

Local testing

With Step Functions you test ASL through the TestState API, which runs one state at a time against AWS with mocked integrations, and stand up or mock the Lambdas and queues it calls. Step Functions Local, the downloadable emulator, is now marked unsupported by AWS and lacks feature parity. The feedback loop runs through AWS.

Here the chart is tested with pytest in-process. A SimulatedClock makes the 3-day timeout instantaneous:

from xstate_statemachine import (
    MachineLogic,
    SimulatedClock,
    SyncInterpreter,
    create_machine,
)
from xstate_statemachine.patterns import RetryPolicy

chart = {
    "id": "order",
    "initial": "charging",
    "context": {
        "attempt": 0,
    },
    "states": {
        "charging": {
            "invoke": {
                "src": "chargeCard",
                "onDone": "awaitingShipment",
                "onError": {
                    "target": "retrying",
                    "actions": "retryBump",
                },
            },
        },
        "retrying": {
            "after": {
                "retryDelay": [
                    {
                        "target": "charging",
                        "guard": "retryCanRetry",
                    },
                    {
                        "target": "paymentFailed",
                    },
                ],
            },
        },
        "awaitingShipment": {
            "on": {
                "SHIPPED": "shipped",
                "CUSTOMER_CANCELLED": "cancelled",
            },
            "after": {
                "259200000": "cancelled",
            },
        },
        "shipped": {
            "type": "final",
        },
        "cancelled": {
            "type": "final",
        },
        "paymentFailed": {
            "type": "final",
        },
    },
}

outcomes = iter(
    [ConnectionError("card network"), "ch_123"]
)  # fail once, then succeed


def charge_card(i, ctx, e):
    r = next(outcomes)
    if isinstance(r, Exception):
        raise r
    return r


policy = RetryPolicy(max_attempts=3, base_ms=2000, jitter="none")
machine = create_machine(
    chart,
    logic=MachineLogic(
        services={
            "chargeCard": charge_card,
        }
    ).merge(policy.logic()),
)

clock = SimulatedClock()
order = SyncInterpreter(machine, clock=clock).start()
assert order.value == "retrying"
clock.increment(2_000)  # backoff
assert order.value == "awaitingShipment"
clock.increment(3 * 24 * 3_600_000)  # three days, instantly
assert order.value == "cancelled"

The same file runs in CI with no Docker, no AWS credentials and no network. xsm simulate chart.json --events +259200000 does the same from a shell.

When Step Functions is still the right call

  • You are already all-in on AWS and the steps are AWS services. Step Functions has direct integrations with 200+ of them (Lambda, ECS, Glue, SageMaker, DynamoDB, SQS), so there is no glue code to write.
  • You want nobody to operate the runtime. No process to keep alive, no scheduler role, no store to back up. AWS keeps executions for up to a year and retries at the service level.
  • Massive fan-out. A distributed Map state over millions of S3 objects is a single ASL state. Doing that yourself means building a job system.
  • Audit and ops out of the box. Execution history, CloudWatch metrics, X-Ray traces and console replay come built in. Compliance teams already know how to read them.

When to choose xstate-statemachine

  • The machine is event-driven, not a pipeline. Users, webhooks and timers all move it, and any state may need to react to them (a subscription, an order, a chat, a device).
  • You want the state machine inside your application, running beside your domain code and your database transaction, testable with pytest in milliseconds.
  • Portability. The same chart runs in XState in the browser and in Python on the server, on any cloud or none.
  • Cost at high event volume. A library costs your compute only, whereas Step Functions Standard bills per state transition.
  • You need hierarchy and parallel regions with statechart semantics, and after timers that are cancelled automatically when a state exits.

You can also use both. A statechart can own the entity’s lifecycle while Step Functions runs a heavy batch job it invokes. The service call is an invoke, and the callback arrives as an event.

Related: Recipes, APScheduler durable timers, Circuit breaker & retry, vs LangGraph.

New to the library? Start with the integrations journey: pick your path, then a fifteen-minute tutorial.