The Saga Pattern: Managing Distributed Transactions Without Two-Phase Commit

A checkout flow I reviewed last year touched four services on every order: payments, inventory, shipping, and loyalty points. The team had wrapped all four in a distributed transaction using a two-phase commit coordinator. Under load it deadlocked roughly once an hour, holding locks across services for hundreds of milliseconds and timing out the others. The fix was not a faster coordinator. It was deleting the coordinator and replacing it with a saga.

A saga trades the immediate, all-or-nothing guarantee of a single transaction for a sequence of local transactions that can be undone. You lose strong consistency across services. You gain a system that does not hold cross-service locks, does not deadlock under load, and degrades in ways you can actually reason about.

Why Not Just Use a Distributed Transaction

The textbook answer to “I need to update four services atomically” is two-phase commit (2PC). One coordinator asks every participant to prepare, then tells them all to commit. It gives you atomicity across services.

It also gives you three problems that get worse under exactly the conditions you care about:

  1. Locks are held for the round trip. Every participant locks its rows from prepare until commit. That window includes network latency to the slowest service. Under load, those locks queue.
  2. The coordinator is a single point of failure. If it crashes after prepare but before commit, participants sit in-doubt, holding locks, waiting.
  3. Most managed services do not support it. You cannot 2PC across Postgres, a payment provider’s API, and an SQS queue. The moment one participant is an external HTTP API, XA is off the table.

This is why distributed transactions are rare in modern microservice stacks, and why the pragmatic approach to microservices leans on eventual consistency instead. The saga is the pattern that makes eventual consistency manageable.

How a Saga Works

A saga splits a business transaction into a sequence of steps. Each step is a local transaction inside one service, committed independently. Each step has a paired compensating transaction that semantically undoes it.

The order saga looks like this:

StepForward actionCompensation
1Reserve inventoryRelease inventory
2Charge paymentRefund payment
3Create shipmentCancel shipment
4Award loyalty pointsRevoke points

The happy path runs steps 1 to 4 in order. If step 3 fails, the saga runs the compensations for steps 2 and 1 in reverse: refund the payment, release the inventory. The order ends up cancelled and every service is back to a consistent state, just not instantly.

The key insight is that a compensation is not a rollback. Step 2 already committed. You cannot un-charge a card with a database rollback, so the compensation is a refund: a new forward operation that undoes the effect. That distinction shapes everything about how sagas are designed.

Choreography vs Orchestration

There are two ways to wire the steps together, and the choice matters more than any other decision in a saga.

Choreography Inventory Payments Shipping event event Orchestration Orchestrator Inventory Payments Shipping command

Choreography

No central coordinator. Each service subscribes to events, does its local work, and publishes the next event. The inventory service emits inventory.reserved, the payment service reacts to it, charges the card, and emits payment.charged, and so on. Control is emergent.

// Payment service, reacting to an event
on('inventory.reserved', async (event) => {
  try {
    const charge = await chargeCard(event.orderId, event.amount);
    await publish('payment.charged', { orderId: event.orderId, chargeId: charge.id });
  } catch (err) {
    // Trigger the compensation chain
    await publish('payment.failed', { orderId: event.orderId });
  }
});

Choreography is genuinely simple for two or three steps. It needs no extra service and maps cleanly onto an event bus. The cost shows up as the workflow grows: the logic is scattered across every service, no single place describes the flow, and tracing a stuck order means reading event logs across four codebases. Cyclic dependencies between services creep in quietly.

Choreography sits naturally on top of understanding event-driven architecture, because the saga is just a meaningful chain of domain events.

Orchestration

A single orchestrator service owns the workflow. It sends explicit commands to each participant and waits for the reply before deciding the next step.

class OrderSaga {
  async run(orderId: string) {
    const state = await this.loadOrCreate(orderId);
    try {
      await this.send('inventory.reserve', orderId);
      await this.send('payment.charge', orderId);
      await this.send('shipping.create', orderId);
      await this.markComplete(orderId);
    } catch (err) {
      await this.compensate(orderId, state.completedSteps);
    }
  }

  async compensate(orderId: string, completed: string[]) {
    // Undo completed steps in reverse order
    for (const step of completed.reverse()) {
      await this.send(`${step}.compensate`, orderId);
    }
  }
}

The workflow lives in one place. You can read it, test it, and put a state machine behind it. Observability is far better because the orchestrator records which step every saga is on. The cost is a new service to build and operate, and the discipline of not letting it grow into a god service that contains all the business logic.

ConcernChoreographyOrchestration
Central coordinatorNoneOne orchestrator
Where the flow livesSpread across servicesOne place
Best for2 to 3 simple steps4+ steps or branching
ObservabilityHard, read event logsEasy, orchestrator state
Coupling riskCyclic event dependenciesOrchestrator becomes a hub
Added infrastructureJust the event busA saga/orchestrator service

My default: choreography until the workflow has four steps or any branching, then orchestration. The crossover point is when “what happens next?” stops having an obvious answer from reading one service.

Compensating Transactions Are the Hard Part

The forward path is easy. The compensations are where sagas go wrong. Four rules from production.

Compensations must be idempotent. A compensation can be triggered more than once because the message that triggers it can be redelivered. Refunding a payment twice is a real incident. Key every compensation by the saga id and the step, and make the second attempt a no-op. This is the same discipline as how to design idempotent API endpoints.

Some steps cannot be compensated. You cannot un-send an email or un-ship a parcel that left the warehouse. Order your saga so that irreversible steps come last, after everything that might fail has already succeeded. If sending the confirmation email is step 4, a failure in step 4 has nothing left to undo.

Compensations can fail too. A refund call can time out. Compensations need their own retries, and a saga that cannot complete its compensation chain must land in a dead-letter state that pages a human. Silent failure here means money stuck in the wrong place.

The world moves on between forward and compensation. By the time you release reserved inventory, someone else may want it. Compensations restore consistency, not the exact prior state. Design them as forward corrections, not time machines.

Sagas Need Reliable Messaging Underneath

A saga is only as reliable as the messages that move it forward. If a step commits its local transaction and then fails to publish the next event, the saga stalls with no error. This is the dual-write problem, and it is why sagas almost always sit on top of the transactional outbox pattern: each step writes its business change and its outgoing message in one local transaction.

The broker choice matters too. Ordering guarantees, redelivery behaviour, and dead-letter support all shape how compensations behave, which is the kind of tradeoff covered in message queues for developers: RabbitMQ vs Kafka vs SQS. And because every step and every compensation involves a network call that can fail, the retry and timeout logic from building resilient APIs with retry and circuit breaker patterns applies directly to the orchestrator.

For the canonical write-ups, Chris Richardson’s saga pattern reference ↗ covers both styles in depth, and the broader catalogue in Martin Fowler’s patterns of distributed systems ↗ places sagas next to the related building blocks.

What to Monitor

A saga that gets stuck halfway is worse than an outright failure, because the data sits inconsistent and nobody knows. Three signals matter:

  1. In-flight saga age. The time since each running saga started. A saga that has been on step 2 for an hour is stuck. Alert on a threshold tied to normal completion time.
  2. Compensation rate. A rising rate of compensations means a downstream service is failing. It is a leading indicator before the failure shows up elsewhere.
  3. Dead-lettered sagas. Any saga that exhausted its compensation retries. This count should be zero, and any non-zero value should page someone.

If you run orchestration, all three are easy because the orchestrator already holds the state. If you run choreography, you will need to build a separate view that correlates events by saga id, which is one more reason orchestration wins as complexity grows.

When Not to Reach for a Saga

Sagas are not free. They add compensations, message plumbing, idempotency keys, and a whole new class of partial-failure states to reason about. Skip them when:

  • The whole operation fits in one database transaction. One service, one ACID transaction, done. Do not distribute what does not need distributing.
  • The steps are truly independent. If awarding loyalty points has no bearing on whether the order succeeds, fire it as a separate best-effort event rather than a saga step with a compensation.
  • Eventual consistency is unacceptable for the use case. Some operations genuinely need a strong guarantee within a single boundary. Redesign the service boundaries before reaching for 2PC.

The Short Version

  • A saga replaces a cross-service distributed transaction with a sequence of local transactions plus compensations.
  • You trade immediate atomic consistency for eventual consistency, and in return you lose cross-service locks and deadlocks.
  • Choreography suits short workflows; orchestration suits anything with four or more steps or branching.
  • Compensations are the hard part: make them idempotent, put irreversible steps last, and give them their own retries and a dead-letter state.
  • Put a transactional outbox under every step so the saga cannot stall on a lost message.

The saga does not make distributed transactions easy. It makes them honest: every partial failure becomes a visible state with a defined recovery path, instead of a lock held somewhere you cannot see. If you have a workflow spanning services and a 2PC coordinator that deadlocks under load, the saga is the trade worth making.

Frequently asked questions

What is the saga pattern?

The saga pattern is a way to manage a business transaction that spans multiple services without a distributed lock. Instead of one atomic commit across every service, the work is split into a sequence of local transactions, each in a single service. After each step completes it triggers the next, either by publishing an event or by being told to by a coordinator. If any step fails, the saga runs compensating transactions that undo the earlier steps in reverse order. The result is eventual consistency rather than the immediate atomic consistency you get inside a single database.

What is the difference between choreography and orchestration sagas?

In a choreography saga there is no central coordinator. Each service listens for events, does its local work, and publishes the next event. Control is distributed and the flow emerges from the chain of reactions. In an orchestration saga a single orchestrator service holds the workflow logic and sends explicit commands to each participant, waiting for replies before moving on. Choreography is simpler for short workflows of two or three steps; orchestration is easier to reason about, test, and observe once a workflow has five or more steps or branching logic.

What is a compensating transaction?

A compensating transaction is the operation that semantically undoes a completed saga step when a later step fails. It is not a database rollback, because the original transaction already committed. If a payment step charged a card, its compensation issues a refund. If an inventory step reserved stock, its compensation releases it. Compensations must be idempotent and should account for the fact that the world may have moved on since the original step, which is why a refund is the compensation for a charge rather than deleting the charge record.

When should you not use the saga pattern?

Skip sagas when the whole workflow can live inside one database transaction. If a single service owns all the data the operation touches, a local ACID transaction is simpler, faster, and strongly consistent. Sagas only earn their complexity when a business operation genuinely spans services or systems that cannot share a transaction. Reaching for a saga inside a monolith that could use a transaction is over-engineering.

How do sagas relate to the transactional outbox pattern?

They compose. A saga step often needs to update its local database and reliably publish an event or command to trigger the next step. The transactional outbox solves exactly that dual-write problem, so each saga step writes its business change and the outgoing message in one local transaction. Without an outbox, a saga step can commit its work and then fail to publish the next event, stalling the whole workflow silently.

Enjoyed this article? Get more developer tips straight to your inbox.

Comments

Join the conversation. Share your experience or ask a question below.

0/1000

No comments yet. Be the first to share your thoughts.