Skip to main content

Step Functions or Plain Code? A Decision Framework for Small Workflows

Here is a question worth asking before adding a state machine to any small product: if the process running step three of five dies right now, what knows that steps one and two already happened? If the honest answer is "a row I wrote myself" or "nothing", there is a durability problem. If the answer is "nothing, and that is fine because every step is cheap to repeat", an orchestrator is probably unnecessary.

This guide is a decision analysis rather than a migration report. It lays out the criteria for choosing between orchestration in code and AWS Step Functions, checked against AWS documentation.

The test is what survives a crash, not how many steps you have

Step count is a poor signal. A five-step flow where every step is idempotent and finishes in seconds is easy to write as plain code. The signals that matter are about time and memory:

  • Does the flow outlive one invocation? A Lambda invocation is capped at 15 minutes, while a Standard workflow can run for up to a year. If the flow must wait on a human, a callback or a slow external job, orchestrating in code means building your own persistence.
  • Do you need to see where one specific execution stopped? Standard executions can be listed and inspected through the Step Functions APIs, and their history is kept for 90 days after they close.
  • Is failure routing different per step? If a failure in step two should trigger compensation but a failure in step four should only alert someone, declaring that beats nested try/catch blocks.
  • Who has to read it later? A state machine definition is a reviewable artifact. A loop with retry counters inside a handler is not.

If none of these apply, keep the orchestration in code and put the effort into idempotent steps. If two or more apply, a state machine starts earning its place.

A state machine charges you in three currencies

Transitions. Standard workflows are billed per state transition, and the error handling documentation notes that retries are treated as state transitions too. That makes your retry policy a cost decision as well as a reliability one. Rates differ by region, so use the official pricing page rather than a figure from a blog.

History events. A Standard execution has a quota of 25,000 history events, and an execution that reaches it fails. A Lambda task state typically generates several events per pass (entered, scheduled, started, succeeded, exited), so check your own execution history. If it is around five, a polling loop that calls a Lambda each time has a ceiling below 5,000 iterations. The documented workarounds are a Distributed Map, where each child execution has its own history, or starting a new execution from a Task state to continue the work.

Payload size. State input and output are limited to 256 KiB. If steps pass large records, pass references such as S3 locations instead of the data itself, which changes your data flow and not just your configuration. Exceeding the limit raises States.DataLimitExceeded.

Standard and Express are different contracts

Aspect Standard Express
Maximum durationUp to one yearFive minutes
Execution semanticsExactly-once workflow executionAt-least-once (asynchronous) or at-most-once (synchronous)
HistoryKept by the service for 90 days after closingNot captured; send it to CloudWatch Logs

The workflow type cannot be changed after creation, so choose it deliberately. One more point is an inference rather than a documented guarantee: "exactly-once" describes the workflow execution, and it does not make task code safe to repeat. A retry re-invokes the task, so the Lambda behind a state still needs to tolerate a second call, and Express's at-least-once mode makes that unavoidable. The orchestrator does not remove the idempotency work. It just makes repeats more visible.

A retry block that states its own limits

By default, when a state reports an error, Step Functions fails the entire execution. The first job is therefore to say what is retried, how often, and where everything else goes. This sketch uses the documented field names, with placeholder values to tune:

{
  "CallPartner": {
    "Type": "Task",
    "Resource": "arn:aws:states:::lambda:invoke",
    "TimeoutSeconds": 60,
    "Retry": [
      {
        "ErrorEquals": ["Lambda.ServiceException",
                        "Lambda.AWSLambdaException",
                        "Lambda.SdkClientException",
                        "Lambda.TooManyRequestsException"],
        "IntervalSeconds": 2,
        "MaxAttempts": 3,
        "BackoffRate": 2,
        "MaxDelaySeconds": 30,
        "JitterStrategy": "FULL"
      }
    ],
    "Catch": [
      { "ErrorEquals": ["States.ALL"],
        "ResultPath": "$.error",
        "Next": "RecordFailure" }
    ],
    "Next": "WriteResult"
  }
}

What each part does, and what the documentation says about it:

  • The named errors. AWS recommends handling the transient Lambda service exceptions in production workflows. Failures inside your own function arrive differently: unhandled errors were historically reported as Lambda.Unknown, and in newer runtimes timeouts appear as Sandbox.Timedout. Match those names, and States.TaskFailed, deliberately. Retrying them blindly repeats a deterministic failure.
  • The numbers. The defaults are an interval of 1 second, 3 attempts and a backoff rate of 2.0. Writing them out lets a reviewer see the budget and keeps the transition cost predictable.
  • Jitter is off by default. JitterStrategy defaults to NONE. Setting it to FULL spreads out simultaneous retries, and MaxDelaySeconds caps how long the backoff can grow.
  • The ordering. Step Functions scans retriers in the order listed, so specific retriers go before broad ones.
  • The catch-all has gaps. States.ALL must appear alone in its list and in the last catcher. It does not catch States.Runtime errors, which are never retriable, and it does not catch States.DataLimitExceeded unless you name that error explicitly. Also, States.TaskFailed matches any known error except States.Timeout, which matters if you assumed it covers timeouts.
  • The timeout. Set TimeoutSeconds on tasks, particularly those that wait for a response from outside, such as activities and callback patterns, so a missing reply cannot leave an execution waiting indefinitely.

If the function also retries internally or an SQS consumer sits upstream, there are now retries at several layers. Pick one layer to own the policy, because attempts multiply instead of adding.

Questions to answer before adopting one

  1. Write down the longest realistic duration of the flow. If it is under a few minutes and every step is idempotent, price the code option first.
  2. Count transitions per execution, including worst-case retries, and multiply by expected volume. Then check the official pricing page.
  3. Count history events for your loops. If any loop is unbounded, design the continuation (child execution or Distributed Map) up front.
  4. Decide the workflow type from semantics, not price. If a repeated start would cause harm, Standard's contract matters more than Express's price.
  5. Decide where failures land. A catcher that routes to a RecordFailure state is only useful if someone is alerted and there is a way to replay, and a failed Standard execution can be redriven, so read the redrive documentation.
  6. Check what a human sees at 3 a.m. For Express, that means enabling CloudWatch logging before it is needed.

Where to stay cautious

Quotas, pricing and error names change over time, and the per-Lambda event count above is a quantity to confirm in your own execution history. No benchmarks are claimed here. Check the Step Functions quotas page and the error handling page on the day you design.

A qualified recommendation: keep orchestration in code while the flow is short, repeatable and owned by one function. Move to a Standard state machine when the flow has to survive a crash, wait longer than a single invocation, or show a stuck run to a human. Choose Express only when at-least-once or at-most-once behavior is acceptable and your logging will tell the story afterward. A state machine is worth its price when it holds state you would otherwise have to hand-build. It is not worth it just to draw the workflow.

Comments