Start a Step Functions Execution With AWS SDK v3

A row of robotic arms working along an automated factory conveyor line

Photo by Homa Appliances on Unsplash

To start a Step Functions execution with AWS SDK v3, send a StartExecutionCommand from @aws-sdk/client-sfn with the state machine ARN, the input as a JSON string, and optionally a unique name. It returns an executionArn right away; poll DescribeExecutionCommand for the result. For Express workflows, StartSyncExecutionCommand runs the workflow and returns the output in one call.

This guide is for Node.js and TypeScript developers who trigger AWS Step Functions from an API, a queue consumer or a script, and need more than the one-line example: an execution that can’t start twice for the same order, a wait that doesn’t hammer the API, and an error message that says which state failed. You’ll get a typed module that starts a Step Functions execution with AWS SDK v3, and the rules that decide how it behaves.

If your service is still on the v2 SDK, the guide to migrate a Node.js app from AWS SDK v2 to v3, step by step covers the client and import changes first.

Prerequisites

  • Node.js 18 or later, TypeScript and tsx, with "type": "module" in package.json.
  • The @aws-sdk/client-sfn package.
  • A deployed state machine and its ARN, such as arn:aws:states:us-east-1:123456789012:stateMachine:process-order.
  • Credentials the SDK can resolve, as described in the guide to AWS SDK v3 credential providers such as fromIni and fromSSO.

Standard or Express: which start call do you need?

The workflow type is fixed when you create the state machine, and it decides which calls work:

Standard Express
Start asynchronously StartExecution StartExecution
Start and wait for output Not available StartSyncExecution
Same name and input again Idempotent while running Not idempotent; names reusable immediately
Maximum duration 1 year 5 minutes
DescribeExecution / GetExecutionHistory Supported Not supported (except Map Run children for DescribeExecution); use CloudWatch Logs

In both cases the input is a JSON string. The Amazon States Language specification defines it as the initial JSON text passed to the start state, with an empty object as the default.

How to start a Step Functions execution with AWS SDK v3, step by step

  1. Create the client oncenew SFNClient({}) at module level, so connections and credentials are reused across calls.
  2. Serialize the inputJSON.stringify your payload and check it’s under 256 KiB in UTF-8. Larger payloads belong in S3, with only the key in the input; a state that needs the object somewhere else can copy it server-side, as in copy and move S3 objects with AWS SDK v3.
  3. Choose a name when duplicates matterUse a business key such as order-o-1042. For Standard workflows, a retry with the same name and input returns the original execution instead of starting another.
  4. Send StartExecutionCommandKeep the returned executionArn; every later call needs it.
  5. Wait with backoffPoll DescribeExecutionCommand until status leaves RUNNING, doubling the delay up to a cap and stopping on a timeout.
  6. Explain failuresOn FAILED, TIMED_OUT or ABORTED, read GetExecutionHistory newest first to find the state and error that caused it.

Example: a typed Step Functions module

step-functions.ts

// step-functions.ts
// Start Step Functions executions with AWS SDK for JavaScript v3: StartExecution with an idempotent name,
// polling DescribeExecution until the run ends, reading the failure from GetExecutionHistory,
// and StartSyncExecution for Express workflows.
import { setTimeout as sleep } from "node:timers/promises";
import {
  DescribeExecutionCommand,
  ExecutionAlreadyExists,
  paginateGetExecutionHistory,
  SFNClient,
  StartExecutionCommand,
  StartSyncExecutionCommand,
  type DescribeExecutionCommandOutput,
} from "@aws-sdk/client-sfn";

const sfn = new SFNClient({});
const MAX_INPUT_BYTES = 256 * 1024; // 256 KiB, measured as UTF-8

function toInput(payload: unknown): string {
  const input = JSON.stringify(payload ?? {});
  if (Buffer.byteLength(input, "utf8") > MAX_INPUT_BYTES) {
    throw new Error("Execution input is over 256 KiB: store the payload in S3 and pass its key instead");
  }
  return input;
}

/** Start a Standard workflow. Reusing `name` with the same input returns the original execution. */
export async function startExecution(stateMachineArn: string, payload: unknown, name?: string): Promise<string> {
  try {
    const res = await sfn.send(new StartExecutionCommand({ stateMachineArn, input: toInput(payload), name }));
    if (!res.executionArn) throw new Error("StartExecution returned no executionArn");
    return res.executionArn;
  } catch (err) {
    if (err instanceof ExecutionAlreadyExists) {
      // Same name, but the earlier execution has closed or had different input.
      throw new Error(`An execution named "${name}" already exists for this state machine: ${err.message}`);
    }
    throw err;
  }
}

export type Finished = Pick<DescribeExecutionCommandOutput, "status" | "output" | "error" | "cause" | "startDate" | "stopDate">;

/** Poll DescribeExecution with capped backoff until the execution leaves RUNNING, or the signal aborts. */
export async function waitForExecution(executionArn: string, signal?: AbortSignal): Promise<Finished> {
  let delayMs = 1_000;
  for (;;) {
    const res = await sfn.send(new DescribeExecutionCommand({ executionArn }), { abortSignal: signal });
    if (res.status !== "RUNNING") {
      return { status: res.status, output: res.output, error: res.error, cause: res.cause, startDate: res.startDate, stopDate: res.stopDate };
    }
    await sleep(delayMs, undefined, { signal });
    delayMs = Math.min(delayMs * 2, 15_000);
  }
}

/** Which state failed and why, read newest-first from the execution history. Standard workflows only. */
export async function lastFailure(executionArn: string): Promise<{ state?: string; error?: string; cause?: string } | undefined> {
  let failure: { state?: string; error?: string; cause?: string } | undefined;
  const pages = paginateGetExecutionHistory(
    { client: sfn, pageSize: 100 },
    { executionArn, reverseOrder: true, includeExecutionData: false },
  );
  for await (const page of pages) {
    for (const e of page.events ?? []) {
      const task = e.taskFailedEventDetails ?? e.lambdaFunctionFailedEventDetails ?? e.taskTimedOutEventDetails;
      if (!failure && task) failure = { error: task.error, cause: task.cause };
      if (!failure && e.executionFailedEventDetails) {
        failure = { error: e.executionFailedEventDetails.error, cause: e.executionFailedEventDetails.cause };
      }
      // Going backwards in time, the first state entered after the failure is the state that failed.
      if (failure && e.stateEnteredEventDetails?.name) return { ...failure, state: e.stateEnteredEventDetails.name };
    }
  }
  return failure;
}

/** Express workflows only: run and wait for the result in one call (up to the 5-minute Express limit). */
export async function runExpress(stateMachineArn: string, payload: unknown): Promise<unknown> {
  const res = await sfn.send(new StartSyncExecutionCommand({ stateMachineArn, input: toInput(payload) }));
  // A failed execution still returns HTTP 200: check status, not exceptions.
  if (res.status !== "SUCCEEDED") throw new Error(`${res.status}: ${res.error ?? ""} ${res.cause ?? ""}`.trim());
  return res.output ? JSON.parse(res.output) : undefined;
}

A caller that starts one execution per order, waits up to 10 minutes and prints the reason if it fails. AbortSignal.timeout is built into Node.js (see the Node.js AbortSignal.timeout documentation) and cancels both the in-flight SDK call and the sleep between polls.

run-order.ts

// run-order.ts
// Usage: AWS_REGION=us-east-1 ORDER_SM_ARN=arn:aws:states:us-east-1:123456789012:stateMachine:process-order npx tsx run-order.ts o-1042
import { lastFailure, startExecution, waitForExecution } from "./step-functions.js";

const stateMachineArn = process.env.ORDER_SM_ARN ?? "";
const orderId = process.argv[2] ?? "o-1042";

// The order ID as execution name: a retried request can't start the same order twice.
const executionArn = await startExecution(stateMachineArn, { orderId, source: "checkout" }, `order-${orderId}`);
console.log(`started ${executionArn}`);

const result = await waitForExecution(executionArn, AbortSignal.timeout(10 * 60 * 1000));
const seconds = result.startDate && result.stopDate ? (result.stopDate.getTime() - result.startDate.getTime()) / 1000 : 0;
console.log(`${result.status} after ${seconds.toFixed(1)} s`);

if (result.status === "SUCCEEDED") {
  console.log("output:", result.output ? JSON.parse(result.output) : null);
} else {
  const failure = await lastFailure(executionArn);
  console.log(`failed in state "${failure?.state ?? "?"}": ${failure?.error ?? result.error ?? ""} ${failure?.cause ?? result.cause ?? ""}`);
  process.exitCode = 1;
}
Terminal

npm install @aws-sdk/client-sfn
npm install --save-dev tsx typescript @types/node
npm pkg set type=module

AWS_PROFILE=dev AWS_REGION=us-east-1 \
ORDER_SM_ARN=arn:aws:states:us-east-1:123456789012:stateMachine:process-order \
npx tsx run-order.ts o-1042
# started arn:aws:states:us-east-1:123456789012:execution:process-order:order-o-1042
# FAILED after 3.4 s
# failed in state "ChargeCard": PaymentDeclined {"reason":"insufficient_funds"}

npx tsx run-order.ts o-1042
# Error: An execution named "order-o-1042" already exists for this state machine: ...

The second run fails on purpose: the first execution has closed, so the same name can’t be reused for 90 days. While an execution is still running, the same name with the same input would have returned it instead of failing.

Execution names and ExecutionAlreadyExists

For Standard workflows, StartExecution is idempotent: calling it with the same name and input as a running execution succeeds and returns the original response. If that execution has already closed, or the input differs, you get ExecutionAlreadyExists (HTTP 400). A name can be reused 90 days after its execution closes. Express workflows don’t deduplicate at all.

That makes the name your deduplication key. Use a value that identifies the work, not a timestamp, and keep it within the rules: 1 to 80 characters, no whitespace, brackets, wildcards or characters such as # % : /. If you log to CloudWatch Logs, stick to letters, digits, - and _. Leave name out and Step Functions generates a UUID, which never collides and never deduplicates. The same thinking applies to writes in the workflow itself; conditional writes, as in the guide to DynamoDB update item with AWS SDK v3 conditions and counters, keep a retried step from applying twice.

Waiting for the result without throttling

DescribeExecution is eventually consistent and throttled per account and Region. As of September 2026 the Step Functions quotas page lists a bucket of 300 calls refilling at 15 per second in US East (N. Virginia), US West (Oregon) and Europe (Ireland), and 250 refilling at 10 per second elsewhere. A tight loop over hundreds of running executions drains that fast. The module starts at one second and doubles up to 15 seconds; the SDK’s own retry settings, explained in the guide to configure retry and timeout settings in AWS SDK for JavaScript v3, handle the occasional throttle on top.

When the caller doesn’t need to block, don’t poll: hand the work to a queue and let the state machine report back, for example with the pattern in send and receive SQS messages with AWS SDK v3 or a notification through SNS publish with AWS SDK v3 in TypeScript.

For short request-response workflows, StartSyncExecution on an Express state machine avoids polling entirely. It returns HTTP 200 even when the workflow fails, so runExpress checks status (SUCCEEDED, FAILED or TIMED_OUT) rather than relying on exceptions.

Which IAM permissions do you need?

Starting acts on the state machine; describing and reading history act on the execution, whose ARN has the form arn:aws:states:region:account:execution:state-machine-name:execution-name.

step-functions-caller-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "StartOrderWorkflows",
      "Effect": "Allow",
      "Action": [
        "states:StartExecution",
        "states:StartSyncExecution"
      ],
      "Resource": [
        "arn:aws:states:us-east-1:123456789012:stateMachine:process-order",
        "arn:aws:states:us-east-1:123456789012:stateMachine:quote-price"
      ]
    },
    {
      "Sid": "WatchOrderExecutions",
      "Effect": "Allow",
      "Action": [
        "states:DescribeExecution",
        "states:GetExecutionHistory"
      ],
      "Resource": "arn:aws:states:us-east-1:123456789012:execution:process-order:*"
    }
  ]
}

The caller doesn’t need iam:PassRole: the state machine runs with its own execution role, which needs permission for whatever its tasks call, such as lambda:InvokeFunction. To derive the caller’s list from code, see how to find the IAM actions your AWS SDK for JavaScript code needs, or paste the module into the IAM policy generator for TypeScript code.

Troubleshooting and common mistakes

  • ExecutionAlreadyExists. The name was used by a closed execution in the last 90 days, or by a running one with different input. Pick a new name or treat it as “already done”.
  • InvalidExecutionInput. The input isn’t valid JSON, often because an object was passed instead of a string. Always JSON.stringify.
  • StateMachineTypeNotSupported. StartSyncExecution was called on a Standard state machine.
  • ExecutionDoesNotExist right after starting, or on an Express ARN. DescribeExecution is eventually consistent and doesn’t support Express executions; retry briefly for Standard, read CloudWatch Logs for Express.
  • AccessDeniedException on describe. The policy grants the state machine ARN, but describe calls need the execution ARN pattern. The steps to troubleshoot AWS IAM access denied errors show how to read the message.
  • The failure points at a Lambda task. The cause holds the function’s error; the guide to investigate Lambda errors with CloudWatch picks up from there, and you can reproduce the call alone with the example to invoke a Lambda function with AWS SDK v3 in TypeScript.

Limits to plan around

  • Execution input and output: 256 KiB as UTF-8 each.
  • Standard execution history: 25,000 events, after which the execution fails; history is kept for 90 days after an execution closes.
  • Open Standard executions: 1,000,000 per account and Region by default; above that, StartExecution returns ExecutionLimitExceeded.
  • StartExecution throttling: for Standard workflows in us-east-1, us-west-2 and eu-west-1, a bucket of 1,300 refilling at 300 per second (800 and 150 elsewhere); Express allows a bucket of 6,000 refilling at 6,000 per second. These are soft quotas.
  • Express: 5 minutes maximum, no execution history API, and StartSyncExecution scales on demand rather than drawing on those buckets.

ChatWithCloud can help you inspect workflows from the terminal: from a read-only profile, ask “Which executions of process-order failed in the last day, and in which state?” It writes and runs SDK v2 code locally, as the page on how ChatWithCloud works explains, and it can be wrong, so treat the answer as a lead.

Frequently asked questions

How do I pass input to a Step Functions execution in Node.js?

Set input on StartExecutionCommand to a JSON string, for example JSON.stringify({ orderId: "o-1042" }). The start state receives it as its input.

How do I wait for a Step Functions execution to finish in SDK v3?

@aws-sdk/client-sfn has no waiter for executions. Poll DescribeExecutionCommand with backoff until status isn’t RUNNING, or use StartSyncExecutionCommand for Express workflows.

What’s the difference between StartExecution and StartSyncExecution?

StartExecution returns immediately with an execution ARN and works for both workflow types. StartSyncExecution works only for Express workflows and returns the final status and output in the same call.

Can I start an execution of a specific state machine version?

Yes. Pass a version ARN (the state machine ARN plus :10) or an alias ARN (plus :PROD) as stateMachineArn. An unqualified ARN runs the latest revision.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud