Step Functions is excellent — and expensive in cognitive load — when your workflow is still “invoke, wait on webhook, continue.” Start with durable in-app patterns (token store + callback Lambda + idempotent resume) and graduate to Step Functions only when branching, human gates, or multi-service compensation justify the state machine.
⚡ TL;DR: Prefer DynamoDB task tokens + SQS/EventBridge callbacks for linear wait-and-resume; keep Lambda handlers idempotent with Powertools; move to Step Functions when you need visual branching, parallel fan-out with join, or audit-grade history. Pair with Lambda Timeouts, Retries, and DLQs and LLM Coding Agents on AWS.
The complexity tax
| Signal | Stay in-app | Step Functions earns it |
|---|---|---|
| Steps | 2–4 linear | 5+ with branches |
| Wait | One external callback | Multiple waits + timers |
| Compensations | Single undo | Saga across 3+ services |
| Auditors | App logs enough | Need execution history UI |
| Team | One squad owns flow | Platform + many producers |
❌ Wrapping every “sleep 30s and retry” in Express Workflows. ✅ Measuring whether operators actually open the SFN console during incidents.
In-app durable wait with a task token
// lib/durable.ts — illustrative callback pattern
import { DynamoDBClient, PutItemCommand, GetItemCommand } from "@aws-sdk/client-dynamodb";
import { randomUUID } from "node:crypto";
const ddb = new DynamoDBClient({});
const TABLE = process.env.TASK_TABLE!;
export async function startWait(input: { orderId: string }) {
const taskToken = randomUUID();
await ddb.send(new PutItemCommand({
TableName: TABLE,
Item: {
pk: { S: `task#${taskToken}` },
orderId: { S: input.orderId },
status: { S: "WAITING" },
ttl: { N: String(Math.floor(Date.now() / 1000) + 86400) },
},
ConditionExpression: "attribute_not_exists(pk)",
}));
// call external system with taskToken as correlation id
return { taskToken };
}
export async function resume(taskToken: string, result: unknown) {
const got = await ddb.send(new GetItemCommand({
TableName: TABLE,
Key: { pk: { S: `task#${taskToken}` } },
}));
if (!got.Item || got.Item.status.S !== "WAITING") {
// ✅ Idempotent: duplicate callbacks are no-ops
return { ok: false, reason: "not_waiting" };
}
// continue business logic…
return { ok: true, result };
}
When to flip to Step Functions
// Only after complexity earns it — sketch
import * as sfn from "aws-cdk-lib/aws-stepfunctions";
import * as tasks from "aws-cdk-lib/aws-stepfunctions-tasks";
const waitForVendor = new tasks.LambdaInvoke(this, "WaitVendor", {
lambdaFunction: vendorFn,
integrationPattern: sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN,
payload: sfn.TaskInput.fromObject({
token: sfn.JsonPath.taskToken,
orderId: sfn.JsonPath.stringAt("$.orderId"),
}),
});
const definition = submit
.next(waitForVendor)
.next(new sfn.Choice(this, "VendorOk?")
.when(sfn.Condition.stringEquals("$.status", "OK"), fulfill)
.otherwise(compensate));
Add SFN when Choice/Parallel/Map + execution replay beat bespoke Dynamo state. Until then, the in-app token table is enough — see also Lambda Warm Pools for keeping resume handlers warm.
Guardrails either way
- Idempotency keys on every resume path
- Explicit TTLs so abandoned waits die
- Alarm on
WAITINGage p99 - Never block a Lambda for wall-clock waits — always callback or SFN
.wait
Closing checklist
✅ Dos
– ✅ Start with token store + callback for linear flows
– ✅ Idempotent resume; TTL abandoned tasks
– ✅ Promote to SFN when branching/saga/audit demand it
– ✅ Measure console usage before mandating SFN
– ✅ Keep payloads small; park blobs in S3
❌ Don’ts
– ❌ Don’t sleep inside Lambda for long waits
– ❌ Don’t invent a second orchestration framework
– ❌ Don’t skip compensation design “until later”
– ❌ Don’t put secrets in task-token payload
Related reading
- Lambda Timeouts, Retries, and DLQs
- Lambda Warm Pools
- LLM Coding Agents on AWS
- Secure AI Sandboxes: Ephemeral ECS Tasks
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
