# Durable Functions for Long-Running Workflows | Volcano

URL: https://volcano.dev/features/durable-functions

> Durable functions on Volcano: write multi-step workflows in JavaScript or Python that checkpoint every step, retry what fails, pause for a person's approval or wait months without holding a runtime, and resume where they stopped for up to a year.

## Workflows that pick up where they left off

Write the whole workflow as one function. Volcano checkpoints every step, so after a crash, a month-long wait or a person's approval it resumes from the last finished step instead of starting over.

```console
$ volcano cloud durable deploy --all
[1/1] Deploying order-pipeline...
  Runtime: nodejs24.x
✓ 1/1 durable function(s) deployment started
$ volcano cloud durable start order-pipeline --input '{"order_id":4417}' --name order-4417
✓ Execution order-4417 started
$ volcano cloud durable executions get order-pipeline 9a8b7c6d-…
Status  succeeded   Duration  16m 53s
# fifteen of those minutes were a wait, and a wait holds no runtime
```

## Long work fails in the middle

Anything with more than one step eventually fails between two of them. The card was charged but the confirmation never went out, the import stopped at row 40,000, the approval arrived after the request waiting for it had timed out. The usual fix is a queue, a state table, a retry policy and a worker that knows where the last one died — a second system to build before the feature ships.

A durable function is that system, written as ordinary code. Wrap the handler in durable() and do the work through ctx.step. Volcano records each result as it completes, so when an attempt crashes or the function suspends on a wait, the next run replays what already finished and carries on from the first step that did not. Waits are held by the platform, so an execution can sit for a week without keeping anything running.

## Finished steps never run again

*Checkpoints*

ctx.step runs a piece of work and stores what it returned. When the execution resumes — after a crash, a retry or a wait — finished steps hand back their recorded result instead of running again. For the call that must never repeat, like a charge, atMostOnce with retry: false records the step before it runs.

- Failed steps retry with exponential backoff
- atMostOnce with retry: false for a charge or an email
- ctx.map fans out with a concurrency cap

```console
step  load-order         succeeded      84 ms
step  charge             succeeded     412 ms
wait  settle             15m           nothing running
map   pack · 12 items    succeeded     concurrency 5
step  dispatch           failed        retrying, attempt 2
✓ resumed at dispatch · the charge was not repeated
```

## Stop for a person's approval

*Human in the loop*

ctx.waitForApproval suspends the execution until someone on the project approves or denies the request, then resumes it with their decision and comment. The request appears under Approvals with the details your code attached, and a timeout makes expiry one more outcome to branch on. A project access token is refused, so a workflow holding one cannot approve its own request.

- Decide in the dashboard, the CLI, the API or an SDK
- No runtime held, and no compute charged, while it waits
- Approval rate and time to decision for every workflow

Image: An approval request open in the dashboard, with its details, a comment and the approve and deny buttons.

## Every execution, where you can see it

*Operations*

Executions are listed per function with their status, region and duration, from the dashboard, the CLI or the SDK. Open one to see the messages it is waiting for and send one by hand, read its result, or stop it — stopping never undoes a step that already finished.

- Every status, from running to succeeded, failed or stopped
- Open waits and message history on every execution
- Results kept for 30 days after an execution ends

Image: A running execution with the messages it waits for, a message being sent and its message history.

## What every durable function gets

- **JavaScript and Python**: The same step, wait, waitUntil, map, parallel and child operations in both, on Node.js 22 and 24 or Python 3.13 and 3.14.
- **Up to a year per execution**: One execution can run for 366 days across as many resumes as it needs, and ctx.wait sleeps through any of it without holding a runtime. Each step gets up to 300 seconds on Hobby or 900 seconds on Superagent.
- **Retries built in**: A failed step retries with exponential backoff. Set the attempts, the delays and which errors count, or turn retries off for that step.
- **Idempotent starts**: The execution name is the idempotency key, so a retried start returns the execution that already exists and is charged once.
- **Cron schedules**: Attach a cron expression. Each tick starts an execution named after the tick, so a retried tick never does the work twice.
- **Metered on what runs**: Executions, operations and GB-seconds have their own monthly allowances on both plans. Time spent waiting is not charged.

## Write it, start it, run it

### JavaScript

```javascript
const { durable } = require('@volcano.dev/sdk/durable');

exports.handler = durable(async (input, ctx) => {
  const order = await ctx.step('load-order', () => loadOrder(input.order_id));

  // Checkpointed before it runs and never retried, so an attempt that dies
  // mid-charge cannot bill the customer twice.
  const charge = await ctx.step('charge', () => chargeCard(order), {
    atMostOnce: true,
    retry: false,
  });

  // Let the charge settle. Nothing runs while the execution waits.
  await ctx.wait('settle', '15m');

  const packed = await ctx.map(
    'pack',
    order.items,
    (item, itemCtx) => itemCtx.step('pack-item', () => packItem(item)),
    { concurrency: 5 },
  );
  packed.throwIfFailed();

  // Large orders wait for a person. The request appears under Approvals, and
  // nothing runs until someone decides or three days pass.
  if (order.total_cents > 100_000) {
    const review = await ctx.waitForApproval('ship-order', {
      title: `Ship order ${order.id}?`,
      description: 'The total is over the auto-ship limit of $1,000.',
      details: {
        order_id: order.id,
        total: order.total_cents / 100,
        items: order.items.length,
        destination: order.destination,
      },
      timeout: '3d',
    });

    // Denied or expired
    if (!review.approved) {
      await ctx.step('refund', () => refund(charge.id), { atMostOnce: true, retry: false });
      return { order_id: order.id, outcome: 'refunded' };
    }
  }

  await ctx.step('dispatch', () => dispatch(order, packed.results));
  return { order_id: order.id, outcome: 'shipped' };
});
```

### Python

```python
from volcano_sdk.durable_authoring import durable


@durable
def handler(event, ctx):
    # Python operations are synchronous, and a step's function receives a scope.
    report = ctx.step("build", lambda scope: build_report(event["account_id"]))
    ctx.step("send", lambda scope: send_report(report), at_most_once=True, retry=False)

    # Give the customer a week. The execution is suspended, not sleeping.
    ctx.wait("grace", "7d")

    opened = ctx.step("check-opened", lambda scope: was_opened(report["id"]))
    if not opened:
        ctx.step(
            "reminder",
            lambda scope: send_reminder(event["account_id"]),
            at_most_once=True,
            retry=False,
        )

    return {"report_id": report["id"], "reminded": not opened}
```

### Start it

```typescript
import { VolcanoAuth } from '@volcano.dev/sdk';

const volcano = new VolcanoAuth({
  apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL!,
  anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,
});

export async function placeOrder(orderId: number) {
  // The execution name is the idempotency key. A double click, or a retry
  // after a timeout, returns the execution that already exists.
  const { data, status, error } = await volcano.durable.start(
    'order-pipeline',
    { order_id: orderId },
    { executionName: `order-${orderId}` },
  );

  // Errors are returned, not thrown: 404 while the function is private, 403
  // without a signed-in user unless it is public, 409 while it is still
  // deploying, and 429 when the project already has its limit of executions
  // running.
  if (error || !data) {
    return { error: error?.message ?? 'no execution returned', status };
  }

  // Starting never waits for the result. The handle comes back at once.
  return { executionId: data.id, state: data.status }; // 'running'
}
```

### Operate

```bash
# Declared durable in volcano-config.yaml:
#   functions:
#     - name: order-pipeline
#       kind: durable
# The app starts it with the user's session, so it admits signed-in users.
volcano cloud durable deploy -f order-pipeline --visibility authenticated

volcano cloud durable start order-pipeline --input '{"order_id":4417}' --name order-4417
volcano cloud durable executions list order-pipeline --status running
volcano cloud durable executions get order-pipeline <execution-id>
volcano cloud durable executions stop order-pipeline <execution-id>

# Hourly. Each tick starts one execution.
volcano cloud durable schedulers create order-pipeline --name hourly --cron "0 * * * *"

# What the build printed, then what the executions logged
volcano cloud durable logs order-pipeline --type build
volcano cloud durable logs order-pipeline --type runtime --follow

# The same handler on your machine. Waits resolve at once unless
# LOCAL_DURABLE_REAL_TIME=true is set before volcano start.
volcano start
volcano durable deploy -f order-pipeline --visibility authenticated
```

## What people run as durable functions

### Orders and payments

Charge once, fan fulfillment out across the items, hold large orders for a person's approval, and refund on a denial. Every step is recorded, so a crash halfway never bills twice.

### Agent runs that outlast a request

Record each model call and tool call as a step, so a run that takes an hour resumes after a failed tool instead of paying for every earlier call again. Before anything irreversible, ctx.waitForApproval holds the run until a person approves it.

### Nightly syncs that finish

A cron tick starts the execution, each page from a flaky third-party API is its own step, and a page that fails retries on its own instead of restarting the sync from page one.

## Works with the rest of Volcano

- [Call your other functions](https://volcano.dev/features/edge-functions): Invoke a standard function from inside a step, so a workflow reuses the endpoints you already deployed.
- [State the workflow reads](https://volcano.dev/features/postgres): A job status or a row to process lives in Postgres, where ctx.waitUntil checks it and your app writes it.
- [Started by a signed-in user](https://volcano.dev/features/authentication): A start made with the user's session reaches the handler with their verified identity on the input, never one the client wrote.
- [Progress on the page](https://volcano.dev/features/realtime): Broadcast from a step as the workflow advances, so the page shows where it is without polling the execution.

## Frequently asked questions

### How is a durable function different from a standard function?

A standard function answers one request within its timeout. A durable function is started rather than invoked: it returns an execution handle straight away, keeps working in the background for up to 366 days, and checkpoints each step so it resumes instead of restarting. The two are separate resources, and a function's kind is fixed when it is created.

### Which languages can I write one in?

JavaScript on Node.js 22 or 24, and Python on 3.13 or 3.14, with the same operations in both. Ruby apps can start and read executions through the Ruby SDK, but the handler itself is written in JavaScript or Python.

### What does a long wait cost?

Nothing while it waits. Compute is charged only for the time your code is actually running, and a suspended execution holds no runtime. The wait itself counts as one operation, as does each step attempt and each waitUntil check, and an execution can use up to 3,000 operations. What a waiting execution does hold is one of your project's concurrent-execution slots, until it ends.

### Can a workflow wait for a person, a webhook or another function?

Yes. ctx.waitForApproval suspends on a request a person on the project approves or denies, from the Approvals page in the dashboard, with volcano cloud durable approvals, or through the API with a platform token. ctx.waitForMessage suspends until a message with that name arrives, sent by a webhook receiver, your backend, a browser or another function with a credential the function's visibility admits, or by hand from the execution in the dashboard. Neither holds runtime while it waits, and either can take a timeout so the workflow moves on without an answer. A message never decides an approval, so keep approvals wherever only a person should answer.

### How are durable functions billed?

On executions, operations and GB-seconds of compute, each with its own monthly allowance and metered separately from function requests. Hobby projects can run up to 10 executions at once with 128 MB each; Superagent projects can run up to 100 with 1 GB each. The full figures are on the pricing page.

### Can I run them on my machine?

Yes. volcano start runs durable functions locally with the same handler and the same volcano-config.yaml, so what works locally deploys unchanged. Waits resolve immediately so a day-long wait takes seconds, and LOCAL\_DURABLE\_REAL\_TIME=true keeps real timing when you need it.

## Get started

Ready to run your first workflow? Create a free account at https://volcano.dev/signup, or have your coding agent follow the setup playbook at https://volcano.dev/setup.md.
