Skip to content
Node.js nd microservices 5 min read

Distributed Transactions & the Saga Pattern

In a monolith, a single database transaction keeps your data consistent: either every write commits or they all roll back. Once you split that monolith into services — each owning its own database — that guarantee disappears. A business operation like “place an order” now spans the Order, Payment, and Inventory services, and there is no shared transaction to bind them together. The Saga pattern is the standard answer: model the workflow as a sequence of local transactions, and undo earlier steps with compensating transactions when a later one fails.

Why two-phase commit doesn’t fit microservices

The classic distributed-transaction solution is two-phase commit (2PC). A coordinator asks every participant to “prepare”, and if all agree, tells them to “commit”. It provides strong ACID guarantees, but it is a poor fit for microservices:

  • It locks resources across the whole prepare/commit window. In a high-throughput system, holding row locks while waiting on a network round-trip across several services kills concurrency.
  • It is synchronous and blocking. If the coordinator or any participant stalls, the entire transaction is stuck, often holding locks the whole time.
  • It assumes everyone speaks XA. Many modern data stores (most NoSQL databases, message brokers, third-party APIs) simply don’t support distributed transactions.
  • It couples availability. The transaction can only succeed if every participant is up — the opposite of the loose coupling microservices aim for.

Sagas trade the immediate, strong consistency of 2PC for eventual consistency with no global locks. Each step commits independently and immediately; if the overall workflow fails, you roll forward to a consistent state by compensating.

The Saga pattern

A saga is a sequence of local transactions T1, T2, … Tn. Each Ti has a corresponding compensating transaction Ci that semantically undoes its effect. If T1, T2, T3 succeed but T4 fails, the saga executes C3, C2, C1 in reverse order.

A compensating transaction is a semantic undo, not a rollback. You can’t un-charge a credit card by deleting a row — you issue a refund. Design Ci as a real business operation that reverses the effect of Ti.

There are two ways to coordinate the steps: choreography and orchestration.

Choreography

Each service reacts to events and emits its own. There is no central controller — the workflow emerges from services listening to one another via a message broker (Kafka, RabbitMQ, etc.).

// payment-service.js — reacts to OrderCreated, emits the next event
import { consumer, publish } from "./broker.js";
import { chargeCard, refund } from "./payments.js";

await consumer.subscribe("OrderCreated", async (event) => {
  try {
    const txId = await chargeCard(event.customerId, event.amount);
    await publish("PaymentCompleted", { orderId: event.orderId, txId });
  } catch (err) {
    // We can't fulfil our step — emit a failure the saga can react to.
    await publish("PaymentFailed", { orderId: event.orderId, reason: err.message });
  }
});

// Compensation: triggered if a *later* step fails.
await consumer.subscribe("OrderCancelled", async (event) => {
  if (event.txId) await refund(event.txId);
});

Choreography is simple and decentralised, but the overall flow is implicit — no single place describes the whole saga, which makes complex workflows hard to reason about and debug.

Orchestration

A central orchestrator owns the workflow. It tells each service what to do via commands and decides the next step (or which compensations to run) based on the replies. The logic lives in one place.

AspectChoreographyOrchestration
CoordinationDistributed (events)Centralised (orchestrator)
CouplingLooseServices coupled to orchestrator
Flow visibilityImplicit, hard to traceExplicit, easy to follow
Best forSimple, 2-3 step sagasComplex, many-step workflows
RiskCyclic event dependenciesOrchestrator becomes a bottleneck

An order-processing saga

Here is an orchestrated order saga. Each step is a local transaction with a matching compensation; if any step throws, the orchestrator unwinds the completed steps in reverse.

// order-saga.js
const steps = [
  {
    name: "reserveInventory",
    action: ({ orderId, items }) =>
      callService("inventory", "POST", "/reserve", { orderId, items }),
    compensate: ({ orderId }) =>
      callService("inventory", "POST", "/release", { orderId }),
  },
  {
    name: "chargePayment",
    action: ({ orderId, customerId, amount }) =>
      callService("payment", "POST", "/charge", { orderId, customerId, amount }),
    compensate: ({ orderId }) =>
      callService("payment", "POST", "/refund", { orderId }),
  },
  {
    name: "scheduleShipping",
    action: ({ orderId, address }) =>
      callService("shipping", "POST", "/schedule", { orderId, address }),
    compensate: ({ orderId }) =>
      callService("shipping", "POST", "/cancel", { orderId }),
  },
];

async function callService(name, method, path, body) {
  const res = await fetch(`http://${name}.internal${path}`, {
    method,
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`${name} failed: ${res.status}`);
  return res.json();
}

export async function runOrderSaga(order) {
  const completed = [];
  for (const step of steps) {
    try {
      console.log(`-> ${step.name}`);
      await step.action(order);
      completed.push(step);
    } catch (err) {
      console.error(`x  ${step.name} failed: ${err.message}`);
      // Unwind everything that succeeded, newest first.
      for (const done of completed.reverse()) {
        console.log(`<- compensating ${done.name}`);
        await done.compensate(order).catch((e) =>
          console.error(`!! compensation ${done.name} failed: ${e.message}`)
        );
      }
      return { status: "aborted", failedAt: step.name };
    }
  }
  return { status: "completed", orderId: order.orderId };
}
// usage
import { runOrderSaga } from "./order-saga.js";

const result = await runOrderSaga({
  orderId: "ord-1001",
  customerId: "cust-42",
  items: [{ sku: "BOOK-9", qty: 1 }],
  amount: 2999,
  address: "221B Baker Street",
});
console.log(result);

When payment fails, the inventory reservation is released and the saga aborts cleanly:

Output:

-> reserveInventory
-> chargePayment
x  chargePayment failed: payment failed: 402
<- compensating reserveInventory
{ status: 'aborted', failedAt: 'chargePayment' }

Best Practices

  • Make every step idempotent. Retries and redeliveries are inevitable; use the orderId (or an idempotency key) so a repeated reserve or charge has no extra effect.
  • Design compensations as semantic undos. Refund, release, cancel — never assume you can simply delete what you wrote.
  • Persist saga state. Store each step’s progress in a durable log or database so the orchestrator can resume after a crash instead of leaving the workflow half-applied.
  • Accept eventual consistency. There will be brief windows where inventory is reserved but the order isn’t confirmed. Model the UI and business rules around it.
  • Avoid compensations that can’t fail-safe. If a compensation itself fails, alert and retry with backoff; never silently swallow it.
  • Prefer orchestration for complex flows. Once a saga exceeds three or four steps, a central orchestrator is far easier to observe and debug than tangled events.
  • Add tracing. Correlate every step and compensation with a trace ID so you can follow a single saga end to end.
Last updated June 14, 2026
Was this helpful?