What you'll learn
Quick Answer
A saga breaks one distributed transaction into a sequence of local transactions, each with a matching compensating action. If a step fails partway through, the saga runs the compensations for every already-completed step, in reverse order, instead of relying on a database rollback that can't span services. It trades atomicity for eventual consistency — intermediate states are visible to the rest of the system before compensation finishes.
Why you can't just ROLLBACK
Inside a single database, a multi-step transaction is easy: wrap the updates in BEGIN/COMMIT, and if anything goes wrong, ROLLBACK undoes all of it. The database guarantees atomicity for you.
A checkout that spans an inventory service, a payment service, and a shipping service has no such guarantee. Each service owns its own database. There is no single transaction manager that can wrap all three and roll them back together.
Two-phase commit (2PC) technically solves this on paper, but it barely survives contact with reality: it holds locks across services for the whole duration, blocks if any participant crashes mid-commit, and simply doesn't work when one "participant" is a third-party payment gateway you don't control the internals of.
The saga pattern is the practical alternative: let each service commit its own local transaction immediately, and design an explicit undo step for each one. If a later step fails, you don't roll back — you compensate.
Local transactions plus compensating actions
A saga is a sequence of local transactions T1...Tn. Each Ti commits independently in its own service. For every Ti, you define a compensating transaction Ci that semantically undoes its effect — not a database rollback, but a new operation that cancels it out.
If step k fails, the saga runs C(k-1), C(k-2), ... C1 — the compensations for every step that already succeeded, in reverse order. Steps that never ran need no compensation.
There are two ways to coordinate this:
- Orchestration — a central orchestrator calls each step directly, tracks which have completed, and decides what to compensate on failure. The whole state machine lives in one place, which makes it far easier to reason about and debug.
- Choreography — each service reacts to events published by the previous one and publishes its own in turn. No central coordinator, but the overall flow is scattered across every service's event handlers, which gets hard to trace as the saga grows.
Most teams start with orchestration for exactly this reason: when something goes wrong, there is one place to look.
A saga that fails and rolls back — real run
Below is a minimal orchestrator for a three-step checkout saga: reserve inventory, charge payment, create shipment. Each step has a matching compensation. The orchestrator runs the steps in order, and on failure, walks the completed steps backward.
const steps = [
{ name: 'reserveInventory', run: () => reserveInventory(orderId, qty),
compensate: () => releaseInventory(orderId, qty) },
{ name: 'chargePayment', run: () => chargePayment(orderId, amount),
compensate: () => refundPayment(orderId, amount) },
{ name: 'createShipment', run: () => createShipment(orderId),
compensate: () => cancelShipment(orderId) },
];
try {
for (const step of steps) { await step.run(); completed.push(step); }
} catch (err) {
for (const step of completed.reverse()) { await step.compensate(); }
}
Running it with a payment amount that gets declined produces this, captured from an actual run:
[reserveInventory] order order-2: reserved 4 widgets (left: 3)
[FAILURE] step failed: card declined for amount 9000
Running 1 compensation(s) in reverse order...
[COMPENSATE releaseInventory] order order-2: released 4 widgets back (left: 7)
SAGA ROLLED BACK for order order-2
Payment never ran, so only inventory needed compensating — and only the reverse of what had actually completed.
The catch: compensations aren't real rollbacks
A compensating action doesn't restore the exact prior state — it semantically cancels the effect, and that difference shows up in practice.
Refunding isn't un-charging. It's a separate transaction: it may take days to settle, appear as a distinct line on a statement, and — this is the part people forget — it can fail on its own. Your compensation code needs a failure path too, usually meaning "retry the refund" or "alert a human," not "assume it worked."
Other requests can see the in-between state. Between inventory being reserved and payment being confirmed, another process querying that order sees a half-finished saga. The common fix is a semantic lock: mark the order PENDING until the saga completes or compensates, so nothing downstream treats a half-done order as final.
Compensations must themselves be idempotent. If the orchestrator crashes and resumes, or a network retry fires twice, the same compensating action can run more than once — releasing inventory twice, for instance. Design each compensation the same way you'd design any retried operation: safe to run more than once.
When to reach for a saga
Use a saga when a single business operation genuinely spans multiple services that each own their own data — a checkout across inventory, payment, and shipping; a travel booking across flight, hotel, and car reservation. In these cases there is no ACID transaction to fall back on, so an explicit sequence of steps and compensations is the honest solution.
Skip it if all the data involved lives in one database. A normal transaction with COMMIT/ROLLBACK is simpler, actually atomic, and doesn't require you to design and test a compensating action for every step. Sagas add real, ongoing complexity — every new step needs a compensation, and every compensation needs its own failure handling — and that cost is only worth paying once you've actually split across service boundaries, not before.
