step functions

A ten minute wait should not cost you Lambda time

A ten minute wait should not cost you Lambda time

"Alexa, goodnight" should do five things: lock the front door, dim whatever is still on, drop the heating to the night setpoint, wait long enough for me to get from the sofa to the bedroom, and then switch everything off.

Four of those are a loop over some devices. The fifth is a ten minute pause, and it is the reason this is not a Lambda function.

What the pause costs

The naive version is time.sleep(600) in the middle of a handler. Three things are wrong with it, in increasing order of how much they matter.

You pay for it. Lambda bills wall time, and ten minutes of a 256 MB function doing nothing is ten minutes of a 256 MB function. It is fractions of a cent and it is still absurd.

You cannot. The maximum timeout is fifteen minutes, so a ten minute wait fits, barely, and leaves no room for the routine to grow. The moment I wanted a twenty minute version for guests, the design was finished.

And a retry replays the whole thing. If the function dies at minute nine, whatever invoked it retries from the top — locking a locked door, re-dimming lights, and waiting another ten minutes. Nothing here is dangerous to repeat, which is luck rather than design.

What a Wait state costs

Nothing. In Step Functions a Wait is a state, and you are billed per state transition, not per second of waiting.

"GracePeriod": {
  "Type": "Wait",
  "Seconds": 600,
  "Comment": "Long enough to get from the sofa to the bedroom",
  "Next": "LightsOut"
}

The whole goodnight routine is six states, and the execution history in the console shows exactly which one a run is sitting in — which turns out to be the feature I use most. A routine that looks stuck at eleven at night is almost always the wait, and now I can see that instead of guessing.

One function, many tasks

Each task state points at the same Lambda function and passes it a name:

"LockUp": {
  "Type": "Task",
  "Resource": "${RoutineTaskFunctionArn}",
  "Parameters": {
    "task": "lock_doors",
    "arguments": {}
  },
  "Retry": [
    {
      "ErrorEquals": ["Lambda.ServiceException", "Lambda.TooManyRequestsException"],
      "IntervalSeconds": 2,
      "MaxAttempts": 3,
      "BackoffRate": 2
    }
  ],
  "ResultPath": "$.lockUp",
  "Next": "DimLights"
}

On the Python side that is a dictionary and a lookup:

TASKS = {
    "lock_doors": lock_doors,
    "dim_lights": dim_lights,
    "lights_off": lights_off,
    "set_setpoint": set_setpoint,
    "set_mode": set_mode,
    "notify": notify,
}


def lambda_handler(event, context):
    name = event.get("task")
    if name not in TASKS:
        raise errors.UnsupportedOperation("unknown routine task {0}".format(name))
    return TASKS[name](event.get("arguments", {}))

Six functions would have meant six deployment packages, six roles and six sets of environment variables, for six things that all talk to the same two tables. One function with a task key is the smaller thing to own. The trade is that they share a timeout and a memory setting, and I have not yet hit a case where that matters.

The retry policy being in the state machine rather than in the code is the quiet win. The lock is the flaky one — it sits behind a bridge that reboots itself now and then — and giving it two attempts five seconds apart is a four-line edit to a JSON file, not a change to any Python at all.

The away version, and why Choice earns its place

The same machinery shuts the flat down when everybody leaves, with one difference at the front:

"StillOccupied?": {
  "Type": "Choice",
  "Choices": [{ "Variable": "$.occupants", "IsPresent": true, "Next": "Cancelled" }],
  "Default": "ShutDown"
}

Phones cross the geofence in a ragged way. Somebody walks to the corner shop, the geofence fires, and thirty seconds later they are back. Putting the check in the state machine rather than in the trigger means the routine can be started optimistically and change its mind, and the console shows a run that ended at Cancelled — which is a much better thing to find than no run at all.

What it does not solve

Step Functions is not free of its own edges. The state machine definition wants the task function's ARN, which means a substitution at deploy time; both my SAM template and my Pulumi program grew a small piece of machinery to inject it. And ASL is JSON, so a typo in a state name is a deploy-time error at best and a runtime one at worst.

Still worth it. The moment a workflow has a pause in it, or a branch, or a step that should be retried differently from its neighbours, the choice stops being about elegance and starts being about which of those things you want to write yourself.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev