Introduction

You are building an online travel booking system. A customer books a flight, a hotel, and a rental car — three separate services, three separate databases. Either all three reservations succeed, or none should, because a flight without a hotel is a problem.

In a single database this would be a transaction: the database guarantees atomicity. But across services, there is no shared transaction log. You need a protocol.

Two main strategies exist. Two-Phase Commit (2PC) holds a global lock — every participant votes, then either everyone commits or everyone aborts. It is atomic but blocking: a coordinator crash mid-protocol can freeze all participants for minutes or longer. Sagas take the opposite approach: each step executes immediately and publishes an event. If a later step fails, earlier steps are compensated — undone by explicit reversal actions. The final state is only eventually consistent, but no service is ever blocked waiting for a lock.

The choice between 2PC and sagas is not a detail — it is one of the most consequential design decisions in distributed systems.

Try It: Run a Saga

The demo below simulates a three-step booking saga: reserve a flight, book a hotel, then rent a car. Each step can succeed or fail. Use the toggles to inject a failure at any step, then run the saga.

<!-- {{c_html_intro}} -->
<div class="hint">{{hint_para}}</div>
<div class="controls">
  <div class="toggle-row">
    <label class="toggle-label">
      <input type="checkbox" id="fail1"> {{lbl_fail_step1}}
    </label>
    <label class="toggle-label">
      <input type="checkbox" id="fail2"> {{lbl_fail_step2}}
    </label>
    <label class="toggle-label">
      <input type="checkbox" id="fail3" checked> {{lbl_fail_step3}}
    </label>
  </div>
</div>
<div class="steps" id="steps">
  <div class="step" id="step1">
    <div class="step-icon">✈</div>
    <div class="step-label">{{lbl_step1}}</div>
    <div class="step-status" id="s1status"></div>
  </div>
  <div class="arrow" id="arrow1">→</div>
  <div class="step" id="step2">
    <div class="step-icon">🏨</div>
    <div class="step-label">{{lbl_step2}}</div>
    <div class="step-status" id="s2status"></div>
  </div>
  <div class="arrow" id="arrow2">→</div>
  <div class="step" id="step3">
    <div class="step-icon">🚗</div>
    <div class="step-label">{{lbl_step3}}</div>
    <div class="step-status" id="s3status"></div>
  </div>
</div>
<div class="log-area" id="log"></div>
<div class="btns">
  <button id="run" type="button">{{btn_run}}</button>
  <button id="reset" type="button" class="ghost">{{btn_reset}}</button>
</div>
/* {{c_css_intro}} */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 14px; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.controls { margin-bottom: .8rem; }
.toggle-row { display: flex; flex-wrap: wrap; gap: .6rem; }
.toggle-label { display: flex; align-items: center; gap: .35rem; font-size: .85rem;
                cursor: pointer; user-select: none; }
.steps { display: flex; align-items: center; justify-content: center;
         gap: .3rem; margin: .6rem 0; flex-wrap: wrap; }
.step { display: flex; flex-direction: column; align-items: center; gap: .2rem;
        width: 82px; padding: .5rem .3rem; border-radius: 10px; border: 2px solid #cdd9e3;
        background: #edf2f6; transition: background .25s, border-color .25s; }
.step.ok  { background: #d4edda; border-color: #28a745; }
.step.fail { background: #f8d7da; border-color: #dc3545; }
.step.comp { background: #fff3cd; border-color: #ffc107; }
.step.pending { background: #e8eef3; border-color: #8ba4b8; }
.step-icon { font-size: 1.5rem; line-height: 1; }
.step-label { font-size: .72rem; font-weight: 600; text-align: center; }
.step-status { font-size: .68rem; color: #555; min-height: 1em; text-align: center; }
.arrow { font-size: 1.2rem; color: #8ba4b8; }
.log-area { background: #f4f6f8; border: 1px solid #cdd9e3; border-radius: 8px;
            padding: .5rem .7rem; margin: .6rem 0; min-height: 3.5rem;
            max-height: 130px; overflow-y: auto; font-size: .8rem; line-height: 1.55; }
.log-area .entry { padding: .05rem 0; }
.log-area .entry.ok   { color: #155724; }
.log-area .entry.fail { color: #721c24; }
.log-area .entry.comp { color: #856404; }
.log-area .entry.info { color: #4a4a4a; }
.log-area .entry.done { color: #0a7d33; font-weight: 700; }
.log-area .entry.abort { color: #c92f3c; font-weight: 700; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .4rem; }
button { font: 600 14px system-ui; padding: .45rem .9rem; border: 1px solid #1d3557;
         background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: not-allowed; }
// Code not found

When all steps succeed the saga commits — all three reservations hold. When a step fails, every earlier step fires its compensating transaction to undo the reservation. The final state is always clean, but it is achieved through reversal, not rollback. Notice that during execution each step is briefly committed in its own service — another service could see a partially-booked state if it queries between steps. That is the price of eventual consistency.

The Real Complexity

Both protocols are theoretically clean but practically hard for complementary reasons.

Two-Phase Commit provides strong atomicity — all participants see the same final state. The cost is a blocking window: if the coordinator crashes after collecting "yes" votes but before sending "commit", every participant is stuck holding its lock with no way to proceed alone. The protocol cannot make progress until the coordinator recovers. Gray and Lamport showed that no two-phase protocol can be both non-blocking and atomic under arbitrary failures — a result that mirrors the halting problem in its unbridgeable gap between what we want and what is achievable.

Sagas are non-blocking: each step runs and releases its lock immediately. But the price is that the overall operation is only eventually consistent. Between step 2 committing and step 3 succeeding, another transaction could observe a half-done state. Compensating transactions must be idempotent (safe to replay) and commutative in some orderings — properties that are not always easy to guarantee.

The CAP theorem frames the ceiling: in a distributed system you can have Consistency or Availability under a Partition, but not both. 2PC prioritizes consistency at the cost of availability (it blocks). Sagas prioritize availability at the cost of consistency (they allow temporary partial states). The nn participants in a 2PC require O(n)O(n) message rounds in the happy path and can require unbounded waiting in the failure path.

Where It Matters

"Keep data consistent across services that cannot share a transaction" is one of the defining problems of modern software:

  • Banking and payments: a transfer debits one account and credits another — often in different systems. Most banks use 2PC internally but switch to saga-like compensation at the boundary between institutions (SWIFT message retries, chargebacks).
  • E-commerce order flows: place order → reserve inventory → charge card → ship. A saga unwinds the inventory reservation if the charge fails; a 2PC would lock the inventory table until the card network responds.
  • Airline and travel booking: the travel example above. Most large booking engines use eventual consistency with compensation — holding a "soft lock" on a seat and releasing it if payment fails.
  • Microservice architectures: as systems decompose into dozens of services, the impossibility of a global lock makes sagas the dominant pattern. Frameworks like Axon, Temporal, and AWS Step Functions provide saga orchestration out of the box.

Understanding both patterns lets you recognize which tradeoff your system is making — and whether it is making it deliberately.

Conclusion

Two-Phase Commit and Sagas are not competing implementations of the same idea — they are answers to different questions. 2PC asks: can every participant atomically agree right now? Sagas ask: can every step succeed eventually, and can every failure be undone?

The blocking behavior of 2PC is not a bug — it is the price of a genuine atomic guarantee. The temporary inconsistency of sagas is not a mistake — it is the cost of staying available under failure. Knowing which price your system can afford is the whole art of distributed transaction design.

The next time you see a booking system refund a charge after a hotel cancels, you have witnessed a compensating transaction in the wild — a saga quietly keeping the books clean across services that will never share a lock.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/two-phase-commit-vs-saga/Content licensed under CC BY-NC 4.0.