Compensating failed Workflow steps

Cloudflare Workflows builds durable, multi-step applications with retries and state persistence across long-running processes. Each step can call external systems and persist state across restarts, but a failure in one step can leave earlier completed work in an inconsistent state.

Saga rollbacks for Workflows now let you declare compensation logic inside each step.do(), so you can reverse the effects of earlier steps when a later one fails terminally.

Consider a funds transfer between two banks:

  1. Debit from Bank A
  2. Credit to Bank B
  3. Send email confirmation

If the credit to Bank B fails, the debit at Bank A is already committed. You cannot "undo" that operation directly; you must issue a new credit operation that semantically reverses the debit. Pairing each operation with its compensation logic is the saga pattern.

Previously, developers built their own tracking for what succeeded, what failed, and what to reverse. Now rollback handlers are arguments to step.do(), and Workflows maintains durability for the rollback itself.

// track what completed so we know what to undo
let debitA;
let creditB;
try {
  debitA = await step.do("debit-bank-a", () => bankA.debit(from, amount));
  creditB = await step.do("credit-bank-b", () => bankB.credit(to, amount));
  await step.do("notify", () => notifyBoth(from, to, amount));
} catch (error) {
  // unwind in reverse. each undo is its own durable step,
  // must be idempotent, and must keep going if one fails.
  if (creditB) {
    try {
      await step.do("reverse-credit-b", () => bankB.debit(to, amount, creditB.id));
    } catch (e) {
      await alertOnCall("reverse-credit-b failed", e);
    }
  }
  if (debitA) {
    try {
      await step.do("refund-debit-a", () => bankA.credit(from, amount, debitA.id));
    } catch (e) {
      await alertOnCall("refund-debit-a failed", e);
    }
  }
  throw error;
}
// each step ships with its own undo. add a step,
// add its rollback right here. no growing catch
// block, no manual ordering, no replay logic.
await step.do("debit-bank-a", () => bankA.debit(from, amount), {
  rollback: async ({ output }) => bankA.credit(from, amount, output.id),
});
await step.do("credit-bank-b", () => bankB.credit(to, amount), {
  rollback: async ({ output }) => bankB.debit(to, amount, output.id),
});
await step.do("notify", () => notifyBoth(from, to, amount));

Rollback semantics

To use rollbacks, pass an options object with a rollback function as the last argument to step.do():

const debit = await step.do(
  "debit-account-a",
  async () => {
    return await bankA.debit({
      accountId: fromAccountId,
      amount,
      idempotencyKey: `${transferId}:debit-account-a`,
    });
  },
  {
    rollback: async () => {
      await bankA.credit({
        accountId: fromAccountId,
        amount,
        idempotencyKey: `${transferId}:rollback-debit-account-a`,
      });
    },
  }
);

// The idempotency keys make both the forward operations and rollback operations safe to retry without duplicating the transfer

const credit = await step.do(
  "credit-account-b",
  async () => {
    return await bankB.credit({
      accountId: toAccountId,
      amount,
      idempotencyKey: `${transferId}:credit-account-b`,
    });
  },
  {
    rollback: async ({ output }) => {
      if (output === undefined) {
        return;
      }

      await bankB.debit({
        accountId: toAccountId,
        amount,
        idempotencyKey: `${transferId}:rollback-credit-account-b`,
      });
    },
  }
);

// If we fail here, we may want to revert all previous payments. Users should not have to wrap their code in complex try-catch logic just to revert two small payments (see below)

await step.do("send-confirmation", async () => {
  await sendTransferConfirmation({ ... });
});

Rollback functions must be idempotent like regular steps: use a payment provider's idempotency key for refunds, and make inventory releases safe to call repeatedly.

When a step fails, rollback handlers execute in reverse step-start order. The details of this execution model matter:

The failed step may still require rollback. A step.do() that registered a rollback handler before failing is rollback-eligible. The step may have partially touched an external system — for example, a payment provider could capture a charge, but the step fails before returning the chargeId to Workflows. Rollback handlers receive output but must handle output === undefined.

Rollback starts only on terminal Workflow failure. If user code catches an error and the Workflow continues, no rollback runs. But if a caught error is followed by another failure that ends the Workflow, previously registered handlers execute in reverse step-start order. Once rollback starts, Workflows finds eligible steps, runs their handlers, then records the final failure.

Ordering follows step-start time. For sequential workflows the order is straightforward — reserve inventory, charge card, create shipment; if shipment fails, refund the card and release inventory. Parallel steps complicate this: completion order can diverge from start order, so Workflows uses reverse start order, not reverse completion order.

Why not a fluent or builder API?

The first design was a fluent form, step.do(...).rollback(...). It reads cleanly, placing the forward action next to its compensation at the call site.

The problem: step.do() already returns a Promise for step output, and Workers treats promise-like values specially because its RPC supports promise pipelining. That pattern lets code call methods on a future value before it fully resolves:

const session = api.authenticate(apiKey);
const name = await session.whoami();

Here session is a handle to a session that will exist shortly. Calling session.whoami() lets Workers forward that call to the remote side early, once authentication creates the session:

BLOG-3317 image4

A fluent rollback API would look like calling .rollback() on the step output. But rollback is not part of the output — it is step options registered before execution begins. A fluent API also muddies step timing. Today step.do() starts the step immediately when called. Developers can start a step, do other work, then await the result:

const first = step.do("first", () => serviceA.call());

await step.do("second", () => serviceB.call());

await first;

With a fluent API, Workflows would need to wait and see whether .rollback() attaches before it knows the full step definition. That could delay sending the step to the engine until after await first resolves, meaning first could start after second completes. Concurrent execution becomes harder to reason about when step timing depends on when a Promise is consumed.

A builder-style API avoids the Promise ambiguity and gives an obvious home for future step options:

const charge = await step
	.saga("charge")
	.do(() => chargeCard())
	.rollback(() => refundCharge())
	.run();

But builders add ceremony: every step needs a final .run(), forgetting it would be easy to miss without tooling, and simple cases begin to look like configuration chains. It would also break the existing step.<action> pattern by introducing a step.saga() builder. Most importantly, it would make step.do() feel like a legacy API rather than the primary primitive.

Rollback as step metadata

step.do(..., { rollback })

The final design treats rollback as explicit metadata on the step. Each rollback is defined within the forward step. Each handler receives the error that triggered rollback, the step context, and the output — either the persisted value from the forward step, or undefined if the step failed before persisting anything.

Rollbacks emit lifecycle events, so you can see when compensation started, which handler failed, and whether rollback succeeded. The original Workflow failure stays distinct: rollback is what Workflows does after failure, not the cause of it.

Custom retry and timeout behavior for rollbacks lives in rollbackConfig, alongside the existing WorkflowStepConfig controls for regular step execution:

{
  rollback: async ({ output }) => {
    await bankA.credit({ accountId: fromAccountId, amount, transferId: `${transferId}-reversal` });
  },
  rollbackConfig: {
    retries: { limit: 10, delay: '30 seconds', backoff: 'exponential' },
    timeout: '2 minutes',
  },
}

This fits the lifecycle-event model. A step.do() already describes a durable unit of work Workflows records, retries, and logs. Rollback is another lifecycle behavior for that unit:

  • The step still starts when step.do() normally starts.
  • The returned promise still represents the step output.
  • Concurrent workflow code keeps its execution model.
  • Retry and timeout options sit next to the rollback handler.
  • Existing step.do() calls work exactly as before.

The explicit form is slightly more verbose than a fluent API, but that explicitness is useful. Operation and compensation stay in one place, no new step builder or promise kind is introduced, and developers who know step.do() only need to learn one additional options object. Less magic, simpler to adopt, clearer to understand.

What the engine has to remember

Adding rollback support changes what Workflows must track for every step. A regular step.do() already produces a durable record: whether the step started, whether it completed, what it returned, and whether it should be skipped on resume. Rollbacks add one more field to that record — whether the step registered compensation logic.

Recovering from a failure therefore requires bringing together two pieces of information. The first is the durable step history, which tells the engine what ran, what finished, and what output was stored. The second is the rollback handler itself: the function that compensates for the step. Workflows does not persist the source of that function. Instead, while the Workflow is running, it keeps a callable reference to the handler.

In Workers RPC, such a reference is called a stub. Stubs let one part of the system invoke code running elsewhere, and they have a lifetime tied to a call or execution context. When a stub needs to outlive that context, Workers RPC offers a dup() method to create another handle to the same target. This model fits rollback cleanly: the durable history records what needs compensation, and the rollback stub gives Workflows a way to call that compensation code. Since a rollback handler may need to survive past the step.do() call that registered it, Workflows keeps its own reference for the rollback phase.

In the common case, where rollback happens within the same engine lifetime, Workflows already holds the stubs it needs. It consults the durable step history to find eligible steps, then invokes the corresponding rollback handlers registered during forward execution.

Recovering after a restart

The picture gets more complicated when the engine is evicted, crashes, or restarts while rollback is pending. The durable step history survives, but the in-memory rollback stubs do not. To rebuild them, Workflows uses replay: a recovery mode that re-runs the Workflow code without re-executing the bodies of completed forward steps.

When replay reaches a completed step.do(), Workflows reads the persisted result instead of running the step again. For rollback recovery, it only needs to reconstruct handlers for steps that had rollback attached and are eligible for rollback. As those calls are replayed, the rollback options re-register the callable stubs.

BLOG-3317 image5

This approach lets Workflows recover the handlers it needs without duplicating the original external side effects. When a Workflow is about to fail, the engine does not ask the application to reconstruct what happened. It already has the history, and can answer the key questions from the persisted record:

  • Which steps started?
  • Which steps finished?
  • Which failed step may still need cleanup?
  • Which steps registered rollback handlers?
  • What output should each rollback handler receive?
  • What order should compensation run in?

Workflows then invokes each rollback stub with a rollback context containing the original error, the step context, and the step output if one was persisted.

Ordering matters here. In normal JavaScript, especially with Promise.all(), completion order does not always match start order. A step that started second may finish first. For rollback, Workflows treats the persisted start order as the stable source of truth and unwinds it in reverse.

Rollback handlers run through the same step machinery as forward steps, so compensation inherits the operational properties you expect from Workflows: retries, timeouts, lifecycle events, logs, and a final recorded outcome. If a rollback handler exhausts its retries, Workflows records the rollback outcome as failed, stops running the remaining handlers, and the Workflow instance ends in the Errored state.

This is the core difference between saga rollbacks and a catch block. A catch block only sees what is in memory at its exact point in your JavaScript execution. Workflows rollback uses persisted step history to decide what already happened, invokes the stubs it has in the common case, and rebuilds missing stubs during recovery. That is why the API attaches rollback to step.do() itself: it is metadata on the durable unit of work, not a separate global error handler.

Roadmap

The first release of rollbacks includes explicit per-step rollback handlers for step.do(), sequential rollback execution, and configurable retries and timeouts for compensation. Planned work includes rollback support for waitForEvent, parallel rollback execution, and rollback support for Python Workflows.

When a multi-step application fails partway through, the hardest question is rarely whether it failed. It is knowing what already happened and what needs to happen next. Saga rollbacks let you put that answer next to each step, so the engine can unwind the work it knows completed.