Skip to content

Saga vs Two-Phase Commit

CoreConceptJuly 21, 20267 min read

A trip booking needs a flight, a hotel room, and a card charge to either all succeed or all unwind — but each lives in a different service with its own database. Wrap them in one ACID transaction and you need every database to agree on commit at the same instant. Let each service commit independently and a card charge can succeed after the hotel booking fails, leaving a customer charged with nowhere to sleep.

Two-Phase Commit (2PC) answers this with a coordinator that locks every participant until all agree to commit. The Saga pattern answers it by running each step as its own local transaction and undoing completed steps with compensating actions if a later step fails. This guide follows one trip booking through both mechanisms, compares choreography and orchestration as two ways to run a saga, and traces a real mid-booking failure to its recovery. For the broker mechanics behind an async saga, see Kafka vs RabbitMQ vs SQS and Event-Driven Architecture.

Two-Phase Commit vs Saga: one lock vs committed steps + compensation
Two-Phase Commit vs Saga: one lock vs committed steps + compensation

Two-Phase Commit (2PC)

2PC introduces a coordinator that owns the commit decision. Phase 1 (prepare): the coordinator asks every participant — flight, hotel, payment — to lock its resources and vote yes or no, without committing yet. Phase 2 (commit): only if every participant voted yes does the coordinator tell all of them to commit; if any voted no, or timed out, it tells all of them to abort. Every participant holds its lock for the entire round trip.

That lock is the mechanism and the cost. If the coordinator crashes after every participant has voted yes but before it sends the commit message, each participant is stuck: it cannot unilaterally commit (the coordinator might still abort) or abort (the coordinator might still commit), so it holds its lock until the coordinator recovers and tells it what happened. This is 2PC's well-documented blocking problem — a single coordinator failure can hold locks across three services indefinitely.

2PC: prepare locks every participant before any commit is allowed
2PC: prepare locks every participant before any commit is allowed

Quick reference

  • Prepare phase: every participant locks and votes; nothing is committed yet.
  • Commit phase: coordinator commits all only if every vote was yes.
  • Blocking problem: a coordinator crash mid-protocol leaves participants holding locks.
  • 2PC needs every participant reachable by one coordinator — usually one administrative domain (distributed SQL, XA transactions), rarely independent microservices.
  • Three-Phase Commit (3PC) reduces blocking with an extra round but adds its own network-partition assumptions — rarely used in practice.
2PC coordinator — happy path
1// Illustrative — 2PC coordinator across three participants2async function bookTripTwoPhaseCommit(booking: TripBooking) {3  const participants = [flightService, hotelService, paymentService];4 5  // Phase 1: prepare — each participant locks its resource and votes6  const votes = await Promise.all(7    participants.map((p) => p.prepare(booking)) // returns "yes" | "no"8  );9 10  if (votes.every((v) => v === "yes")) {11    // Phase 2: commit — only reached if every vote was yes12    await Promise.all(participants.map((p) => p.commit(booking.id)));13    return { status: "booked" };14  }15 16  await Promise.all(participants.map((p) => p.abort(booking.id)));17  return { status: "aborted" };18}19// Every participant holds its lock from prepare() until commit()/abort() arrives.
Coordinator crash between prepare and commit
1// All three voted "yes". Coordinator crashes before sending commit/abort.2// flightService, hotelService, paymentService are all now blocked:3//   - Cannot commit unilaterally (coordinator might still send abort)4//   - Cannot abort unilaterally (coordinator might still send commit)5//   - Each holds its lock until the coordinator recovers and replays its log6//7// Recovery requires the coordinator to persist its decision before crashing,8// then replay in-doubt transactions from a durable log on restart.

Remember this

2PC buys strict atomicity by having every participant hold a lock until the coordinator's decision arrives — a coordinator crash mid-protocol blocks all of them.

The Saga Pattern

A saga replaces one distributed transaction with a sequence of independent local transactions, each committed immediately in its own service. Book the flight — done. Reserve the hotel — done. Charge the card — done. No cross-service lock is ever held; each step is final the moment it commits.

The trade is that a saga cannot roll back like a database transaction, because earlier steps already committed and other actions may have happened since. Instead, every step that can fail after an earlier step succeeded needs a compensating action: CancelFlight undoes BookFlight, ReleaseHotel undoes ReserveHotel. A compensation is a new business action recorded in its own right — a refunded charge, a released seat — not a magic undo of history.

Saga: each step commits locally; a later failure compensates in reverse
Saga: each step commits locally; a later failure compensates in reverse

Quick reference

  • Each step is a local ACID transaction in its owning service — no cross-service lock.
  • Compensating actions are forward-moving business actions, not a database rollback.
  • Order matters: compensate completed steps in reverse order of commit.
  • A saga is not atomic — an observer can see the booking mid-flight (flight booked, hotel not yet reserved).
  • Sagas fit independently-owned services and long-running processes better than 2PC ever can.
Saga steps — local transactions only
1// Illustrative — each step commits locally; no cross-service lock2async function bookTripSaga(booking: TripBooking) {3  const flight = await flightService.book(booking.flightId); // commits4  const hotel = await hotelService.reserve(booking.hotelId); // commits5  const charge = await paymentService.charge(booking.cardId, booking.total); // commits6  return { flight, hotel, charge };7}8// If reserve() throws, book() already committed — there is nothing to "roll back."
Same saga with compensation on failure
1async function bookTripSaga(booking: TripBooking) {2  const flight = await flightService.book(booking.flightId);3  try {4    const hotel = await hotelService.reserve(booking.hotelId);5    try {6      const charge = await paymentService.charge(booking.cardId, booking.total);7      return { status: "booked", flight, hotel, charge };8    } catch (chargeError) {9      await hotelService.release(hotel.id); // compensate hotel10      throw chargeError;11    }12  } catch (hotelError) {13    await flightService.cancel(flight.id); // compensate flight14    return { status: "compensated", reason: "hotel_unavailable" };15  }16}17// Each catch block only undoes steps that actually committed before this one.

Remember this

A saga trades one all-or-nothing lock for a sequence of committed local transactions plus a compensating action for every step that might need undoing.

Choreography vs Orchestration

A saga still needs something deciding what happens next after each step. Choreography does this with events: FlightBooked triggers the hotel service to reserve a room, HotelReserved triggers payment, and a failure event triggers whichever services need to compensate — no service owns the whole sequence. Orchestration puts a dedicated saga orchestrator in charge: it calls each service directly, tracks which steps have committed, and decides when to call compensating actions.

Choreography keeps services decoupled but scatters the booking's logic across every participant's event handlers — tracing "why did this booking fail" means reading code in three services. Orchestration centralizes that logic in one place, readable end to end, at the cost of a new component every saga step now depends on.

Skimmer: same saga, two different places the sequencing logic lives.
StyleMechanismTrade-off
ChoreographyServices react to each other's events; no central coordinatorFully decoupled; the overall sequence is implicit, spread across event handlers
OrchestrationOne orchestrator calls each service and tracks saga state explicitlyOne place to read the whole flow; the orchestrator becomes a dependency for every step
Choreography (event reactions) vs orchestration (one coordinator process)
Choreography (event reactions) vs orchestration (one coordinator process)

Quick reference

  • Choreography: each service publishes an event and reacts to events from others.
  • Orchestration: one process calls services in sequence and records saga state.
  • Choreography scales participant count without growing the orchestrator; orchestration keeps the sequence auditable in one place.
  • Mixed approach: orchestrate the core booking, let peripheral services (analytics, notifications) choreograph off the same events.
  • Either way, persist saga state (or rely on the event log) so a crashed process can resume instead of restarting from step one.

Remember this

Choreography decouples services at the cost of scattering the sequence; orchestration centralizes the sequence at the cost of a shared dependency every step now has.

Recover One Booking Failure End to End

Trigger. Booking trip-903: the flight books, the hotel reserves, then the payment provider declines the card. Symptom. Without compensation, the customer now holds a flight and a hotel room they never paid for — and the hotel's inventory shows one fewer room than it actually has. Root mechanism. Each step committed as its own local transaction the instant it succeeded; there is no database transaction spanning all three to roll back.

Recovery. The orchestrator (or the payment-declined event, in choreography) triggers compensation in reverse order: ReleaseHotel(trip-903) first, then CancelFlight(trip-903). Both compensations are idempotent — replaying ReleaseHotel on an already-released room is a no-op, not a double-release. Verification: query flight and hotel inventory for trip-903 and confirm both show released; query payment for the decline record and confirm no partial charge exists. Prevention: run every saga step with a bounded timeout and an idempotency key, and alert when a saga sits in a non-terminal state longer than its expected completion window — a stuck saga is a silent version of 2PC's blocking problem, just without the lock.

trip-903: payment declines after flight and hotel already committed
trip-903: payment declines after flight and hotel already committed

Quick reference

  • Trigger: a later step fails after an earlier step already committed.
  • Compensate in reverse order — undo the most recent commit first.
  • Idempotent compensations: a replayed ReleaseHotel or CancelFlight is a no-op, not a second release.
  • Alert on saga age in a non-terminal state — an orchestrator crash mid-saga looks like 2PC's blocking problem without the lock.
  • Verification means querying every participant's actual state, not trusting that the compensation call returned 200.
Pseudo-code: compensation on payment decline
1# PSEUDO-CODE — orchestrator reacting to PaymentDeclined2on PaymentDeclined(bookingId):3    emit ReleaseHotelRequested(bookingId)4 5on HotelReleased(bookingId):6    emit CancelFlightRequested(bookingId)7 8on FlightCancelled(bookingId):9    mark_saga_terminal(bookingId, status="compensated")
Recovery verification checklist
1# After compensation, query all owners for trip-903:2# flight status                == "cancelled"3# hotel reservation             == "released"4# payment record                == "declined" (no partial charge)5# saga status                   == "compensated"6# saga non-terminal age         == 0

Remember this

A saga's failure recovery is compensating completed steps in reverse — verified by querying every participant's real state, not by trusting one API response.

Choose by Lock Scope and Failure Tolerance

Start from who owns the data, not from which pattern sounds more modern. All participants share one database, or one XA-capable transaction manager, and the transaction is short: 2PC (or your database's native distributed transaction) still works — this is common inside a single distributed SQL cluster, rare across independently-owned microservices. Participants are separate services with separate databases, possibly owned by different teams, and the process can take seconds to days: use a saga.

The deciding question is whether you can tolerate a lock held across services for the duration of the slowest participant. A trip booking that waits on an airline's API cannot hold a cross-service lock for that round trip — a saga's committed-then-compensate model is the only one that keeps each service independently available while the slow step runs.

Choose by who owns the data and how long the process runs
Choose by who owns the data and how long the process runs

Quick reference

  • Single administrative domain, short transaction, strong atomicity required → 2PC or a native distributed transaction.
  • Independently-owned services, long-running or externally-dependent steps → saga.
  • A saga needs a compensating action designed for every step that can fail after an earlier step commits — design it before the step, not after the incident.
  • Neither pattern is free: 2PC costs availability during the coordinator round trip; a saga costs the guarantee that intermediate states are never externally visible.
  • Re-evaluate when a 'temporary' 2PC transaction starts spanning services outside one team's ownership — that is the signal to migrate to a saga.

Remember this

Pick 2PC only when one coordinator can reach every participant quickly inside one domain — everything else, including any step with real external latency, needs a saga's compensating actions instead.

Key takeaway

2PC keeps every participant locked until one coordinator's commit decision arrives — simple to reason about, but blocking the instant that coordinator fails or a participant is slow. A saga replaces the lock with committed local transactions and compensating actions, trading strict atomicity for independent availability at every step.

Practice (30 min): implement bookTripSaga for flight, hotel, and payment as three local transactions with compensating cancel/release functions. Force the payment step to fail after the flight and hotel steps succeed, and verify your orchestrator calls ReleaseHotel then CancelFlight in that order. Make both compensations idempotent and call ReleaseHotel twice to confirm the second call is a no-op. Pass when querying all three services shows a fully compensated trip-903 and no non-terminal saga older than your expected completion window.

Share:

Related Articles

This guide is for backend engineers who know HTTP and database transactions but need to decide where six microservice pa

Read

RabbitMQ is a broker: producers publish messages; exchanges route them; queues buffer work; consumers process and acknow

Read

Five Docker containers with REST between them is not a production microservices system. Clients hit a load balancer and

Read

Keep learning

Follow a structured path or browse all courses to go deeper.