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.

<!-- Saga demo — three steps that can each be toggled to fail -->
<div class="hint">A saga runs each step in its own service. If a step fails, all earlier steps fire compensating transactions to undo their work. Toggle the checkboxes to inject failures, then click Run Saga.</div>
<div class="controls">
  <div class="toggle-row">
    <label class="toggle-label">
      <input type="checkbox" id="fail1"> Fail flight
    </label>
    <label class="toggle-label">
      <input type="checkbox" id="fail2"> Fail hotel
    </label>
    <label class="toggle-label">
      <input type="checkbox" id="fail3" checked> Fail car
    </label>
  </div>
</div>
<div class="steps" id="steps">
  <div class="step" id="step1">
    <div class="step-icon">✈</div>
    <div class="step-label">Reserve flight</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">Book hotel</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">Rent car</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">Run Saga</button>
  <button id="reset" type="button" class="ghost">Reset</button>
</div>
/* Visual states for each saga step */
* { 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; }
// Saga runner — each step commits locally; failures trigger reverse compensation
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));

// Step definitions with labels and messages
const STEPS = [
  { id: 'step1', statusId: 's1status', failId: 'fail1', label: 'Reserve flight',
    doMsg: '→ Step 1: Reserving flight…', okMsg: '✓ Flight reserved (committed in flight service)', failMsg: '✗ Flight reservation FAILED',
    compMsg: '↩ Compensating: cancelling flight reservation' },
  { id: 'step2', statusId: 's2status', failId: 'fail2', label: 'Book hotel',
    doMsg: '→ Step 2: Booking hotel…', okMsg: '✓ Hotel booked (committed in hotel service)', failMsg: '✗ Hotel booking FAILED',
    compMsg: '↩ Compensating: cancelling hotel booking' },
  { id: 'step3', statusId: 's3status', failId: 'fail3', label: 'Rent car',
    doMsg: '→ Step 3: Renting car…', okMsg: '✓ Car rented (committed in car service)', failMsg: '✗ Car rental FAILED',
    compMsg: '↩ Compensating: releasing car reservation' }
];

// Append an entry to the event log
function logEntry(text, cls) {
  const log = document.getElementById('log');
  const e = document.createElement('div');
  e.className = 'entry ' + cls;
  e.textContent = text;
  log.appendChild(e);
  log.scrollTop = log.scrollHeight;
}

// Update a step card's visual state
function setStepState(step, state, statusText) {
  const el = document.getElementById(step.id);
  el.className = 'step ' + state;
  document.getElementById(step.statusId).textContent = statusText;
}

function resetUI() {
  document.getElementById('log').innerHTML = '';
  STEPS.forEach(s => setStepState(s, '', ''));
  document.getElementById('run').disabled = false;
}

// Main saga runner — async so we can animate step by step
// Each step commits independently in its own service (no global lock)
async function runSaga() {
  document.getElementById('run').disabled = true;
  document.getElementById('log').innerHTML = '';
  STEPS.forEach(s => setStepState(s, 'pending', 'waiting…'));

  const committed = []; // track which steps committed so we can compensate them
  let failedAt = -1;

  for (let i = 0; i < STEPS.length; i++) {
    const step = STEPS[i];
    const shouldFail = document.getElementById(step.failId).checked;
    logEntry(step.doMsg, 'info');
    setStepState(step, 'pending', 'running…');
    await delay(600);

    if (shouldFail) {
      // This step failed — stop executing forward
      setStepState(step, 'fail', 'FAILED');
      logEntry(step.failMsg, 'fail');
      failedAt = i;
      break;
    } else {
      setStepState(step, 'ok', 'committed');
      logEntry(step.okMsg, 'ok');
      committed.push(step);
    }
  }

  if (failedAt === -1) {
    // All steps succeeded — saga committed
    logEntry('✓ Saga committed — all three reservations hold.', 'done');
  } else {
    // Some step failed — run compensating transactions in reverse order
    // Compensate in reverse order (last committed first)
    logEntry('⚠ Failure detected — running compensating transactions (reverse order)…', 'info');
    for (let i = committed.length - 1; i >= 0; i--) {
      const step = committed[i];
      await delay(500);
      setStepState(step, 'comp', 'compensated');
      logEntry(step.compMsg, 'comp');
    }
    logEntry('✗ Saga aborted — all earlier steps have been compensated. State is clean.', 'abort');
  }
}

function delay(ms) { return new Promise(r => setTimeout(r, ms)); }

document.getElementById('run').addEventListener('click', runSaga);
document.getElementById('reset').addEventListener('click', resetUI);
resetUI();
Result
Console

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.