Databases

Backend · Databases

Distributed Transactions: Two-Phase Commit vs. Saga Pattern

When a single transaction has to span multiple services and databases, you can't just wrap it in BEGIN/COMMIT — here's how 2PC and sagas each try to fake atomicity, and why one of them lost.

10 min read
Distributed Transactions2PCSaga Pattern

A single database gives you atomicity for free. Wrap a set of writes in BEGIN and COMMIT, and either all of them land or none of them do — the database's write-ahead log and lock manager take care of the rest. The moment those writes span two different databases, or two different services that each own their own database, that guarantee disappears. There is no shared log, no shared lock manager, and no single component that can look at every write and decide "commit" or "abort" for all of them at once.

This is the exact problem an order-placement flow runs into every day: reserve inventory in one service, charge a card in another, schedule a shipment in a third. Each of those is a perfectly consistent local transaction on its own. What's missing is a way to make the whole thing all-or-nothing — so that a declined card doesn't leave inventory reserved forever, and a warehouse outage doesn't leave a customer charged for nothing.

Two-Phase Commit: a coordinator asks permission first

Two-Phase Commit (2PC) is the classic answer, and it's built directly into most relational databases and message brokers that support distributed transactions (XA being the best known standard). A single coordinator drives the whole thing in two rounds:

  • Prepare phase — the coordinator asks every participant "can you commit this?" Each participant does all the work needed to guarantee it could commit — validating constraints, writing to its own log — and replies "yes" or "no", but doesn't actually make the change visible yet. A "yes" is a promise: from this point on, that participant must be able to commit if told to, no matter what happens to it in the meantime.
  • Commit phase — if every participant voted "yes", the coordinator tells all of them to actually commit. If even one voted "no", it tells all of them to abort instead. Either way, every participant does the same thing, which is exactly the atomicity guarantee we were missing.

It's a neat protocol on paper, and it works well inside a single database engine coordinating its own internal shards. The trouble starts once the participants are independent services over a network, with their own uptime and their own failure modes.

Where 2PC actually breaks

The first problem is blocking. Once a participant votes "yes" in the prepare phase, it must hold every lock involved in that transaction until the coordinator's final instruction arrives — it can't commit early, and it can't safely abort on its own, because the coordinator might still tell it to commit. If the coordinator is slow, overloaded, or unreachable, every participant just sits there holding locks, unable to serve other requests that touch the same rows.

The second problem is worse: the coordinator is a single point of failure for the whole protocol. If it crashes after collecting votes but before sending the final decision, participants are left in what's literally called the uncertain state — they know they promised to commit, but not whether the group as a whole actually decided to. They cannot guess; guessing wrong breaks atomicity. They can only block and wait for the coordinator to recover and tell them the answer.

Locks held hostage

2PC trades a small window of coordination overhead for something much more expensive under failure: locks held across a network round-trip, for as long as the slowest participant or the coordinator itself takes to recover. In a system with many services and unreliable networks, that's not an edge case — it's a recurring source of latency spikes and cascading contention.

The Saga pattern: no locks, just a plan for going backwards

The Saga pattern gives up on synchronous, all-or- nothing atomicity entirely, and replaces it with something weaker but far more robust: a sequence of independent local transactions, where every step that changes state has a matching compensating action that semantically undoes it if a later step fails.

Reserve inventory, charge the card, create the shipping label — each of those commits immediately and locally, with no locks held across services. If charging the card fails, there's no "rollback" in the database sense, because the inventory reservation already committed. Instead, the saga runs the inventory service's compensating action — release the reservation — to bring the system back to a consistent state.

javascript
// Order placement saga: local steps + compensations
const steps = [
  {
    action: () => inventoryService.reserve(orderId, items),
    compensate: () => inventoryService.release(orderId, items),
  },
  {
    action: () => paymentService.charge(orderId, amount),
    compensate: () => paymentService.refund(orderId, amount),
  },
  {
    action: () => shippingService.createLabel(orderId),
    compensate: () => shippingService.voidLabel(orderId),
  },
];

async function runSaga(steps) {
  const completed = [];
  for (const step of steps) {
    try {
      await step.action();
      completed.push(step);
    } catch (err) {
      // Roll back everything that already succeeded, in reverse order
      for (const done of completed.reverse()) {
        await done.compensate();
      }
      throw err;
    }
  }
}

Notice what compensations are not: they're not a literal undo of the byte-level change, they're a business-level reversal. Refunding a charge isn't the same operation as never having charged it — it's a new transaction that cancels out the effect. That distinction matters, because it means compensations have to be designed deliberately, not derived automatically the way a database rollback is.

Orchestration vs. choreography

Sagas come in two flavors, differing in who decides what happens next.

  • Orchestration — a central orchestrator explicitly calls each service in order, tracks how far the saga has progressed, and triggers compensations on failure. This keeps the whole flow readable in one place and makes it easy to add monitoring, retries, and timeouts, but the orchestrator becomes a piece of shared infrastructure that every step's logic routes through — and a growing catalog of business processes means a growing catalog of orchestrator logic to maintain.
  • Choreography — there is no central coordinator. Each service publishes an event when it finishes its local transaction (InventoryReserved, PaymentFailed), and the next service in the flow reacts to that event on its own. This avoids a single shared component and lets services evolve independently, but the overall flow is no longer written down anywhere — it emerges from which services happen to listen for which events, which gets genuinely hard to trace and debug as the number of steps grows.

Most teams start with choreography because it feels decoupled, and migrate to orchestration once a saga grows past three or four steps and "what happens when payment fails" stops being answerable by reading any single service's code.

So which one should you use?

2PC is not obsolete — it's the right tool when participants are few, tightly coupled, on a reliable low-latency network, and genuinely need immediate, linearizable consistency across all of them. That description fits shards within one database cluster far better than it fits independent microservices owned by different teams, which is exactly why 2PC has mostly disappeared from service-to-service communication even though it's alive and well inside database internals.

Sagas are the default for microservice architectures precisely because they never hold a cross-service lock — every participant commits and moves on, so one slow or unavailable service degrades that one step instead of freezing every other transaction touching the same data. The cost is that consistency becomes eventual rather than immediate: there's a real window where inventory is reserved but payment hasn't been confirmed yet, and any code that reads state during that window needs to know it might be looking at a saga in progress. Compensations also have to be designed as carefully as the forward steps — a compensation that itself can fail (a refund that bounces) needs its own retry and escalation story, or the saga can get stuck half-undone.

The best distributed transaction is the one you avoid

Before reaching for either protocol, look for a service boundary that makes the problem disappear. Idempotent operations (so a retried step is safe), designing steps so partial completion is harmless on its own, or simply keeping data that must be transactionally consistent inside one service and one database — all of these remove the need for cross-service coordination entirely, which is always cheaper than coordinating well.

Key takeaways

  • A single database gives atomicity for free; once a transaction spans independent services or databases, that guarantee has to be rebuilt explicitly.
  • 2PC uses a coordinator-driven prepare-then-commit protocol to get all-or-nothing behavior, but participants must hold locks until the final decision arrives.
  • 2PC's real failure modes are blocking on a slow coordinator and an unrecoverable 'uncertain' state if the coordinator crashes mid-protocol.
  • Sagas replace atomicity with a chain of local transactions plus compensating actions that undo completed steps when a later step fails.
  • Orchestration centralizes saga logic and is easier to trace; choreography avoids a central component but makes the overall flow harder to see.
  • Sagas are the default for microservices because they hold no cross-service locks, at the cost of eventual consistency and carefully designed compensations.
2026 — Built by Arpit Mishra