Two-Phase Commit vs. Sagas: Blocking or Compensation

By Sergey Nosov

16 October 2026

A customer places an order. The order service records it, the payment service charges the card, the inventory service reserves the stock, and the shipping service schedules a pickup. That is four services and four databases, with no transaction that spans them. Keeping their data apart is the point of separate services.

Now step three fails. The item is out of stock, or the inventory service is down. The card is charged and the order exists, but nothing will ship. That is partial success, and partial success is the enemy. It is worse than total failure, because total failure is at least honest.

Consensus protocols such as Raft and Paxos solve a neighboring problem: they keep the replicas of one cluster in agreement. Here each service is consistent on its own; the question is how one business decision holds across all four. Most answers belong to one of two families. Make the steps atomic together, as two-phase commit does, or commit each step on its own and recover from a failure afterward, as a saga does. Two-phase commit pays for its guarantee with blocking. A saga pays with compensation.

Both families must keep the uncertainty somewhere until the outcome is known. Pat Helland, then at Amazon, described the difference in a 2007 paper. With distributed transactions, the uncertainty “is manifest in the locks held on data.” Without them, “the management of uncertainty must be implemented in the business logic.”

This article covers what each one guarantees, what each costs, and how to choose. Most of it is about sagas, where the engineering lives: a compensation is not an undo, a failed call may have succeeded, and nothing isolates a saga from other requests. It includes a saga orchestrator in C#, and I tested every sample on .NET 10.

Two-Phase Commit: Vote, Then Decide

Two-phase commit dates from the 1970s, and Jim Gray and Leslie Lamport call it “the classic transaction commit protocol.” A coordinator runs it in two phases:

The happy path is short. Here is the coordinator’s side, with retries and recovery left out:

public async Task<Decision> CommitAsync(
    Guid txId, IReadOnlyList<IParticipant> participants)
{
    // Phase 1: collect the votes.
    var votes = await Task.WhenAll(
        participants.Select(p => VoteAsync(p, txId)));
    var decision = votes.All(v => v == Vote.Yes)
        ? Decision.Commit
        : Decision.Abort;

    // The commit point: log the
    // decision before sending it.
    await decisionLog.WriteAsync(txId, decision);

    // Phase 2: yes voters wait for
    // this, holding their locks.
    foreach (var p in participants)
        await p.FinishAsync(txId, decision);
    return decision;
}

private static async Task<Vote> VoteAsync(
    IParticipant p, Guid txId)
{
    try
    {
        return await p.PrepareAsync(txId)
            .WaitAsync(VoteTimeout);
    }
    catch (Exception)
    {
        // No answer counts as no.
        return Vote.No;
    }
}

The write to the decision log is the line that matters most. The two-phase commit entry in my Software Development Principles series calls that instant the commit point. Once the commit decision is durable, the transaction is committed, even if the coordinator crashes before telling anyone. Before that point, a coordinator that crashes can safely abort on recovery. That is also why no answer counts as no: an aborted transaction can be retried, but a commit cannot be taken back.

A real coordinator resends its decision until every participant acknowledges it, and keeps the decision long enough to answer a participant that recovers and asks. What you get is atomicity: every participant commits or none does. Isolation comes from each participant’s own concurrency control: a participant that uses locks holds them until the decision arrives. Those held locks are where the costs begin.

What Two-Phase Commit Costs

It blocks. A participant that has voted yes can neither commit nor abort on its own: it has promised to follow the decision, and it does not know what the decision is. If the coordinator fails in that window, the participant waits with its locks held, and so does every transaction that must lock those rows. In their 2006 paper “Consensus on Transaction Commit,” Gray and Lamport put it plainly: “The failure of that coordinator can cause the protocol to block, with no process knowing the outcome, until the coordinator is repaired.”

One way out is a default outcome, set in advance. SQL Server, whose distributed transactions run through the Microsoft Distributed Transaction Coordinator (MS DTC), has a server option for transactions that the coordinator cannot resolve. Microsoft says that “to minimize the possibility of extended down time, an administrator might choose to configure this option either to presume commit or presume abort.” A presumption is a guess. When it disagrees with what the coordinator decided, the transaction is no longer atomic.

The coordinator is a single point of failure. The fix is consensus. Gray and Lamport’s Paxos Commit runs a consensus instance on each participant’s vote. With 2F + 1 coordinators, it “makes progress if at least F + 1 of them are working properly.” Classic two-phase commit is its special case with F = 0. The price is more messages: about 2FN more for N participants, in the variant that adds no message delay. Google’s Spanner runs two-phase commit over Paxos groups, and its 2012 paper says that doing so “mitigates the availability problems.”

Three-phase commit protocols tried another fix, usually by choosing a new coordinator when the first one fails. Gray and Lamport knew of none “that provides a complete algorithm proven to satisfy a clearly stated correctness condition.”

Every participant must speak the protocol. Each one has to support the vote and keep its promise across a crash. Many databases do, through standards such as the X/Open XA interface between a transaction manager and a resource manager. A payment processor’s REST API does not.

The closest a payment API comes is an authorization, which in Stripe’s words “guarantees the amount by holding it on the customer’s payment method.” A later capture or cancellation settles it. That is a reservation, not a vote. It expires on its own; Stripe says an online card authorization is usually valid for 7 days. And no coordinator ties it to your database. An email gateway has no vote to give at all.

Even the runtime has to support it. When I connected to two SQL Server databases inside one TransactionScope on .NET 10, the second connection threw NotSupportedException: “Implicit distributed transactions have not been enabled.” The opt-in is TransactionManager.ImplicitDistributedTransactions, which Microsoft’s documentation marks as Windows-only.

When I set the flag after that failure, the runtime refused: “Set this flag once at the start of your program.” Set at startup, with PublishAot off, it let the second connection promote the transaction to MS DTC, and the scope completed. With PublishAot on, the default in .NET 10 file-based apps, the promotion failed instead: built-in COM “has been disabled via a feature switch.”

Availability multiplies. A transaction that needs every participant is up only when all of them are up. Five participants that fail independently, each up 99.9 percent of the time, give the transaction about 99.5 percent, as my article on availability by the numbers explains. And because the coordinator waits for the slowest vote, the slowest participant sets the pace of every transaction it joins.

Spanner’s authors defended the protocol anyway, against critics who included Helland. They wrote that it is “better to have application programmers deal with performance problems due to overuse of transactions as bottlenecks arise, rather than always coding around the lack of transactions.” That argument holds inside one system that controls every participant, its replication, and its clocks. Across services owned by different teams and vendors, every cost above applies.

Helland had once advocated platforms that guarantee global serializability, and by 2007 he likened them to the Maginot Line. His verdict: “In general, application developers simply do not implement large scalable applications assuming distributed transactions.”

My summary: two-phase commit is the right tool inside one trust domain, such as a distributed database coordinating its own shards, or two databases under one transaction manager. Across independent services, it rarely survives contact with production.

Sagas: Commit Each Step, Compensate on Failure

Sagas came later, in 1987, from Hector Garcia-Molina and Kenneth Salem of Princeton University. Their paper, titled simply “Sagas,” was about long-lived transactions that “hold on to database resources for relatively long periods of time” and delay the shorter, more common ones. The fix was to write such a transaction as a sequence of shorter ones that commit as they go. The paper discusses only a centralized database, “although clearly they can be implemented in a distributed database system.” Microservices adopted the idea decades later.

Each step of a saga is a local transaction. It commits at once, in its own database, and releases its locks when it finishes, so no lock ever spans services. The price is that every step that may have to be reversed needs a compensating transaction. In the authors’ words, a compensating transaction “undoes, from a semantic point of view, any of the actions performed by Ti, but does not necessarily return the database to the state that existed when the execution of Ti began.”

Their example of backward recovery still shows the basic shape. Steps T1 and T2 have committed when the saga aborts halfway through T3. The database rolls back T3 like any other aborted transaction, and the saga runs C2 and C1, the compensations for the completed steps, in reverse order. In the opening example, the saga would refund the card and cancel the order. The paper’s guarantee is that either every step completes or every completed step is compensated. That is atomicity of the outcome in business terms, not isolation.

The paper also describes forward recovery, which finishes the missing steps instead of compensating the completed ones. Pure forward recovery, it notes, assumes that every step “will eventually succeed if it is retried enough times.” A saga with a pivot, described below, uses both kinds of recovery.

Choreography or Orchestration

Choreography has no central brain. The order service publishes OrderCreated; the payment service hears it, charges the card, and publishes PaymentCaptured; inventory hears that, and so on. It is loosely coupled and has no single point of control, and the workflow as a whole exists nowhere in the code. When it breaks at 2 a.m., someone reconstructs from event logs what was supposed to happen.

Microsoft’s saga guidance calls choreography a good fit for simple workflows with few services and no need for coordination logic, and notes that new steps can make the workflow confusing. Each service must also commit its change and publish its event together, a two-system problem of its own; my article on CQRS solves it with a transactional outbox.

Orchestration puts one component in charge. The orchestrator holds the saga’s state, tells each service what to do next, and keeps each step’s compensation beside the step, so the whole workflow is readable in one place. The costs are one more service to run and the temptation to let the orchestrator grow into a god object that knows the business of every service. The same guidance warns that the orchestrator is a point of failure because it manages the whole workflow, which is one reason its own state has to be durable.

My bias, for the record: choreography for two or three steps in a stable flow, and orchestration for anything longer, anything that branches, and anything a person will have to debug under pressure. The rest of this article orchestrates.

An Orchestrated Saga in C#

The orchestrator below declares each step together with its compensation, so nobody can add a compensable step without writing its compensation. The compensation stops being a ticket someone files after the incident. It becomes part of the feature, written in the same pull request.

Steps come in three kinds, named as in Microsoft’s saga guidance. Compensable steps can be compensated. The pivot is “the point of no return in the saga.” Retryable steps follow the pivot and must eventually succeed. A service reports a definite no, such as a declined card, by throwing StepRejected: a promise that no attempt of the step took effect. Any other exception means the outcome is unknown.

// A definite "no": no attempt
// of this step took effect.
public sealed class StepRejected(string reason)
    : Exception(reason);

public enum StepKind { Compensable, Pivot, Retryable }

public sealed record SagaStep<T>(
    string Name,
    StepKind Kind,
    Func<T, string, Task> Run,
    Func<T, string, Task>? Compensate = null);

Each step receives the saga’s input and an idempotency key. Here is the order saga, declared with a small builder:

var placeOrder = new SagaBuilder<PlaceOrder>()
    .Step("create-order",
        run: (o, key) => orders.CreatePendingAsync(o.Id, key),
        compensate: (o, key) => orders.CancelAsync(o.Id, key))
    .Step("reserve-stock",
        run: (o, key) =>
            stock.ReserveAsync(o.Id, o.Sku, o.Qty, key),
        compensate: (o, key) => stock.ReleaseAsync(o.Id, key))
    .Step("authorize-card",
        run: (o, key) =>
            payments.AuthorizeAsync(o.Id, o.Total, key),
        compensate: (o, key) => payments.VoidAsync(o.Id, key))
    .Pivot("capture-payment",
        (o, key) => payments.CaptureAsync(o.Id, key))
    .Then("confirm-order",
        (o, key) => orders.ConfirmAsync(o.Id, key))
    .Then("schedule-pickup",
        (o, key) => shipping.SchedulePickupAsync(o.Id, key))
    .Then("send-confirmation",
        (o, key) => email.SendConfirmationAsync(o.Id, key))
    .Build(sagaLog);

The order of the steps is deliberate, as the section on ordering below explains. The call placeOrder.ExecuteAsync(order, order.Id) runs the saga, with the order’s ID as the saga’s ID. The executor reads the saga’s log, then runs the steps, writing each transition to the log before it moves on:

public async Task<string> ExecuteAsync(T input, string sagaId)
{
    var log = await sagaLog.ReadAsync(sagaId);
    if (log.Contains("completed")) return "completed";
    if (log.Contains("compensated")) return "compensated";
    if (log.Contains("compensating"))
        return await CompensateAsync(input, sagaId);

    foreach (var step in steps)
    {
        if (log.Contains($"done:{step.Name}")) continue;
        await sagaLog.AppendAsync(sagaId, $"start:{step.Name}");
        try
        {
            var key = $"{sagaId}:{step.Name}";
            await RetryAsync(() => step.Run(input, key));
        }
        catch (Exception ex) when (ShouldCompensate(step, ex))
        {
            await sagaLog.AppendAsync(sagaId, "compensating");
            return await CompensateAsync(input, sagaId);
        }
        await sagaLog.AppendAsync(sagaId, $"done:{step.Name}");
    }
    await sagaLog.AppendAsync(sagaId, "completed");
    return "completed";
}

// Before the pivot, any failure
// turns the saga back. At the
// pivot, only a definite no does.
private static bool ShouldCompensate(SagaStep<T> step, Exception ex) =>
    step.Kind == StepKind.Compensable ||
    (step.Kind == StepKind.Pivot && ex is StepRejected);

Compensation runs newest step first and records each compensation as it finishes:

private async Task<string> CompensateAsync(T input, string sagaId)
{
    var log = await sagaLog.ReadAsync(sagaId);
    // Compensate every step that
    // started, newest first, even
    // the one that failed: it may
    // have run.
    foreach (var step in steps.Reverse())
    {
        var compensate = step.Compensate;
        if (compensate is null ||
            !log.Contains($"start:{step.Name}") ||
            log.Contains($"compensated:{step.Name}"))
            continue;
        var key = $"{sagaId}:{step.Name}:compensate";
        await RetryAsync(() => compensate(input, key));
        await sagaLog.AppendAsync(sagaId, $"compensated:{step.Name}");
    }
    await sagaLog.AppendAsync(sagaId, "compensated");
    return "compensated";
}

// Each attempt reuses the key, so
// a service that honors it applies
// the step once.
private static async Task RetryAsync(Func<Task> call)
{
    for (var attempt = 1; ; attempt++)
    {
        try
        {
            await call();
            return;
        }
        catch (Exception ex)
            when (ex is not StepRejected && attempt < 3)
        {
            await Task.Delay(100 * attempt);
        }
    }
}

Every call carries an idempotency key built from the saga’s ID and the step’s name. The key is the same on every attempt, so a service that honors it applies the step once. A refund issued twice is a new bug, not a fix. The key protects only while the service remembers it, so keys must outlive the longest time a saga can stay open. Compensations get keys of their own, so a service never mistakes one for a replay of its step. My article on idempotency in APIs covers what the services must do with those keys.

The log turns a crash into a pause: a new orchestrator reads it and carries on, forward or compensating. To test that, I declined the card on one order and made the orchestrator crash after the stock service released the hold but before the log recorded the release. Then a fresh orchestrator ran the same saga against the same log:

orders   order-44 pending
stock    2 x SKU-7 held
payments card declined
payments no hold to void
stock    hold released
** orchestrator crashed **
stock    release replayed
orders   order-44 canceled
saga     compensated

The restart repeated the release, and the stock service recognized the key and did nothing. The order was canceled once, and nothing stayed held. In other runs, a reservation whose reply was lost succeeded on retry and was held only once. One that applied its change and then timed out on every attempt was compensated anyway. A capture that went through but lost its reply left the saga open, with nothing compensated, until a restart replayed it under the same key and completed the saga.

This is a teaching sample. Its log lives in memory, and the crash is simulated: the log throws at the chosen moment, and a new orchestrator object takes over. A production log must be durable, and it must keep the saga’s input and the version of its steps, so that a restart repeats the same work. Only one orchestrator may run a saga at a time. Retries need backoff with jitter, every call needs a timeout, a scheduled job must resume open sagas, and a saga that stays open too long needs an alert.

Where Sagas Bite

Sagas are not free either. Five parts bite.

A Compensation Is Not an Undo

A sent email stays sent. A charge is compensated by a refund, which is a second transaction, not a charge that never happened. Stripe’s documentation says that a refund “might incur a fee” and that the processing fees of the original charge “aren’t returned.” Garcia-Molina and Salem make the general point with a letter: to compensate for one, “send a second letter explaining the problem.” Some actions may not be compensable at all: “if a transaction fires a missile, it may not be possible to undo this action.”

So design every step so that its compensation is meaningful, not merely possible. A compensation is new business logic, and it must account for everything that happened since its step ran. Microsoft’s guidance on the Compensating Transaction pattern warns that simply restoring the original state “can overwrite changes from other concurrent application instances.” The paper’s airline example shows the fix. To cancel a seat, subtract one from the number of reservations; never write back the number that existed when the seat was reserved.

A Failed Call Is Not a Rolled-Back Step

In the 1987 paper, a step is a database transaction, so a step that fails is rolled back, and the database has already erased it. Across a network, a failed call means only that no success came back. The step may have run and lost its reply, or it may still be running.

That is why, when the executor turns back, it compensates every step that started, including the one that failed. Temporal’s documentation recommends registering each compensation before its step runs, because that “ensures the compensation runs even if the Activity fails after partial completion. For example, a credit card may be charged but the Activity fails before returning success.” AWS’s saga sample does likewise: when its inventory update fails, the orchestrator runs Revert Inventory before Remove Order.

So every compensation must succeed when there is nothing to compensate. The opposite race happens too: a slow request can reach a service after its compensation. Apache Seata, a distributed transaction framework, documents both cases for its saga mode. In an empty compensation, “the compensation service is executed even though the original service was not.” In hanging, “the compensation service executes before the original service,” and the late request then takes effect with nothing left to compensate it.

Seata’s advice is to allow the empty compensation, record the business key, and refuse a later request with the same key. In my test, every compensation leaves that record, and the service then refuses a late original request. The record is also a reason to compensate even after a definite no. In the crash test above, the executor still asked to void the declined card’s hold. The payment service found none and said so, but it recorded the void, so it would refuse a late authorization for that order.

There Is No Isolation

Garcia-Molina and Salem are explicit: “other transactions might see the effects of a partial saga execution.” When a compensation runs, “no effort is made to notify or abort transactions that might have seen the results” it reverses. Between the charge and the refund, another request can read an order marked paid that is seconds from being canceled, and act on it.

The countermeasures make the uncertainty visible in the data, which is Helland’s point again. The oldest one is in the paper: money moving between accounts belongs in the database, in “a relation for funds in transit.” An audit that runs mid-saga then finds all the money, provided it knows to look there. Microsoft’s saga guidance lists more, including these three:

The sample saga leans on semantic locks: the order is pending, the stock is held rather than deducted, and the card is authorized but not charged. Nothing that another service would act on looks final until the pivot. Customers may still see the hold: Stripe notes that some card statements “don’t always distinguish between authorizations and captured (settled) payments.”

Order the Steps by Risk

Put the steps most likely to fail early and the step hardest to compensate last. The opening example got this wrong: it charged the card before checking the stock, so a routine stockout left a refund behind. The declared saga reverses that. The order starts as pending, the stock is held, and the card is only authorized, which Stripe lets you cancel “at no cost.”

The capture is the pivot. After it, every remaining step must complete, so confirming the order, scheduling the pickup, and sending the email only move forward. Charge the card before you send the confirmation email, never after. Retries get past a temporary failure, not a permanent one, so check what the later steps need, such as a deliverable address, before the pivot. Microsoft’s Compensating Transaction guidance agrees: irreversible steps should occur “only after all critical validations succeed.”

The executor enforces the point of no return. Before the pivot, any failure turns the saga back. At the pivot, only a definite no does. A timeout there leaves the saga open, because the capture may have happened, and compensating the earlier steps would then cancel an order the customer has paid for. To find out whether it happened, retry the capture under the same key or ask for the payment’s status. The authorization sets the deadline: Stripe releases the funds if it expires before capture.

After the pivot, a failure leaves the saga open for another attempt or a person; it is never compensated. The builder also refuses a compensable step declared after the pivot, and a step name used twice.

Compensations Fail Too

A compensation is a transaction like any other. Its service can be down, its call can time out, and its code can have bugs. Garcia-Molina and Salem saw this coming: if a compensating transaction keeps failing, “the system is stuck.” They proposed two answers: an alternate compensating transaction, and manual intervention, “definitely not an elegant solution, but it is a practical one.” Microsoft adds that the system “should record progress so that it can resume the compensating transaction from the point of failure.” That is the saga log’s job, and why the executor records each compensation as it finishes.

Choosing, and Where Each Already Lives

Pick two-phase commit when every participant is a database under one transaction manager, the transaction is short, and no step may commit unless all of them do. That is where it lives today: inside distributed databases such as Spanner, and between SQL Server databases under MS DTC. Some databases stop at the partition instead. Azure Cosmos DB for NoSQL, for example, supports ACID transactions “for operations within the same logical partition key.”

Pick a saga when the steps cross service or vendor boundaries, run too long to hold locks, or include a participant that cannot vote. The business must also tolerate visible inconsistency for as long as a saga runs. As of October 2026, the tooling includes:

If you are about to build an orchestrator from scratch, stop and check these first. The sample above is short because it leaves out what they provide.

Takeaways

Consensus decides which value wins inside one cluster. A saga decides how a workflow ends well across systems that will never share a lock.

One exercise is worth doing. Find a workflow that touches two systems, such as a database and a queue, or a service and a vendor’s API. Write down what happens if the second step fails after the first has committed, and check whether anyone has written the compensation. You may find a saga that nobody knew was a saga.

Further Reading