Utopia Tech
Engineering4 min read

How we built saga rollbacks for Cloudflare Workflows

Cloudflare Workflows allows you to build durable, multi-step applications with built-in retries and state persistence across long-running processes. When a Workflow executes, each step can call external systems, retry failures, and persist state across restarts. But if one step fails, it may leave earlier work from completed steps in an inconsistent or partial state. Today we’r

UT

Utopia Tech

June 25, 2026 · 4 min read

Share

Cloudflare Workflows allows you to build durable, multi-step applications with built-in retries and state persistence across long-running processes. When a Workflow executes, each step can call external systems, retry failures, and persist state across restarts. But if one step fails, it may leave earlier work from completed steps in an inconsistent or partial state.

Today we’re shipping saga rollbacks for Workflows, allowing you to declare rollback logic within the step itself, in case of failure. For example, consider a workflow for transferring funds between accounts at two different banks: Debit from account at Bank A Credit to account at Bank B Send email confirmation to both account owners What happens if Step 2, the credit to account at Bank B, fails?

Once the debit succeeds at Bank A, the transaction is committed and the money has left its system. As the orchestrator of the transaction, you cannot simply “undo” the operation in Bank A's system. Instead, the money must be credited back to the account at Bank A through a new operation that semantically reverses the first one.

This pairing of an operation and its compensation logic is called the saga pattern . Before today, developers had to implement their own compensation logic to track what succeeded, what failed, and what actions should be taken upon failure, outside of the steps’ direct definitions. Now, you can define compensation logic for each step.

do() as an argument within the steps themselves, maintaining your workflow’s durability for the rollback as well. // 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; } Without rollbacks // 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)); With rollbacks Try it out To use rollbacks, just pass an options object containing 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 should be idempotent, just like regular Workflow steps.

If you refund a charge, use the payment provider's idempotency key. If you release inventory, make the release safe to call more than once. If any step fails, the rollback handlers will execute in reverse step-start order.

It sounds simple: run the undo steps when something fails. In practice, there are a few details that make the API and execution model important. 1.

The failed step may still need rollback. A failed step. do() can still be rollback-eligible if it registered a rollback handler.

The rollback will not start if user code catches an error and the Workflow continues, but if a step error is caught and the Workflow later fails for another reason, rollback can still run for previously registered handlers, which execute in reverse step-start order. Why? The step may have partially interacted with an external system before failing.

For example, a payment provider may capture a charge, but the step may fail before returning the chargeId to Workflows. That is why rollback handlers receive output , but must handle output === undefined . 2.

Rollback only starts when the Workflow fails. Adding a rollback handler does not mean every step error triggers rollback. If user code catches an error and continues, the Workflow continues.

Rollback starts when the Workflow itself is about to fail terminally. When rollback starts, Workflows finds eligible step. do() calls, runs their rollback handlers, then records the final Workflow failure.

  1. Ordering has to be predictable. For sequential Workflows, rollback order feels obvious: Reserve inventory.

Charge card. Create shipment. If shipment fails, refund the card and release the inventory.

Parallel steps make this more subtle. Completion order can differ from start order, so Workflows uses reverse step-start order instead of reverse completion order.

Originally published at blog.cloudflare.com

Share
▸ Want a deeper look?

Talk to an architect about applying this to your stack.

60-minute technical evaluation, no obligation. We'll map the ideas in this article to your environment.

Skip to main content