The Saga Design Pattern: Safe Multi-Step Operations
by Puneeth kumar, System Analyst

Let's start with a small story. Say a customer places an order. We create it, reserve the stock, and charge their card. Everything works. Then the last step, booking the delivery, fails because the courier's API is down.
So what now? The money has left the customer's account, the stock is held, and the order will never arrive. How do we get back to a clean state?
If you've ever developed a feature where multiple steps must succeed, you've likely encountered this issue. The Saga pattern is a well-known way to deal with it. Let's walk through it together in plain words, with a little code and a few mistakes that are easy to make.
Why can't the database save us?
If all of this lived in one database, it would be easy: you'd wrap the steps in a transaction, and if anything failed, the database would roll everything back as if nothing had happened.
But real systems are rarely that tidy. For example:
- You run microservices: one service takes the order, another processes the payment, another manages inventory. Each has its own database, so there's no single transaction across them.
- Your app talks to outside systems: a payment provider, a bank, a courier, an email service, and so on. They run on someone else's servers. Your database has no idea how to un-charge a card there.
- Even in a monorepo or a monolith, part of the work often runs later in a background job, or waits days for a webhook. Each piece commits on its own.
Different setups, same trouble: one business action, many places that can't commit or roll back together.
You might ask: why not use a distributed transaction, like two-phase commit? In theory you could. In practice, it makes everyone wait for the slowest system, it can get stuck if the coordinator dies, and most outside APIs simply don't support it. So what can we do instead?
The idea: small steps, each with an undo
Here's the trick. A saga splits the big operation into small steps. Each step does its own work and commits right away. And for every step, you also write the step that undoes it.
If everything works, you're done. If a step fails, you walk backwards and run the undo steps for everything that already succeeded.
Look at the picture at the top again. Steps 1 to 3 worked, but step 4 failed. So we refund the card, release the stock, and cancel the order. Back to where we started.
Think of booking a trip. You book the flight, then the hotel, then the car. No cars left? You'd cancel the hotel, then the flight. Same idea.
The concept originated in 1987. It comes from a database paper by Hector Garcia-Molina and Kenneth Salem, about long transactions that held locks for too long. It fits today's systems surprisingly well.
An undo is not a rollback
One thing that is easy to miss: a database rollback makes a change vanish, as if it never happened. A saga's undo step is a new action that cancels the effect of the old one.
Say the card was charged. We send a refund, and the customer still sees both on their statement. Or say a confirmation email went out. We send a cancellation email, because we can't unsend the first one.
This also means that for a short time, the system can look half-finished. If everything goes well, it settles into a consistent state, either fully done or fully undone, just not instantly. This is called eventual consistency. It's the trade we make: in return, no system has to wait on the others or hold locks while a long operation finishes.
Who decides what runs next?
Here's a simple question: who tells step 2 to start? There are two common answers.

Choreography has no boss. Each service does its job and announces it, like "order created" or "payment failed". The other services listen and react. It's like dancers who all know their moves. This is lovely for a small flow with two or three steps. But as the flow grows, a simple question gets tricky: "what happens after payment succeeds?" The answer ends up scattered across many services.
Orchestration has a conductor. One piece of code, the orchestrator, knows the whole plan. It calls each step in order, remembers where it is, and runs the undo steps when something fails. You can read the whole flow in one place.
So which one should you pick? For a tiny flow, choreography is a nice fit. Once money is involved, or the flow has more than a handful of steps, we find orchestration easier to live with, because there's one place that knows exactly where every operation stands.
A tiny orchestrator in TypeScript
The core idea is small enough to fit on one screen. A step has an execute and an optional compensate (the undo):
TypeScript
type Step<Ctx> = {
name: string;
execute: (ctx: Ctx) => Promise<void>;
compensate?: (ctx: Ctx) => Promise<void>;
};
type Result =
| { status: 'completed' }
| { status: 'compensated'; failedStep: string; error: unknown }
| { status: 'compensation_failed'; failedStep: string; errors: unknown[] };
async function runSaga<Ctx>(steps: Step<Ctx>[], ctx: Ctx): Promise<Result> {
const done: Step<Ctx>[] = [];
for (const step of steps) {
try {
await step.execute(ctx);
done.push(step); // remember what succeeded
} catch (error) {
// go backwards over the steps that succeeded
const errors: unknown[] = [];
for (const finished of [...done].reverse()) {
try {
await finished.compensate?.(ctx);
} catch (compError) {
errors.push(compError); // keep going, undo as much as we can
}
}
return errors.length === 0
? { status: 'compensated', failedStep: step.name, error }
: { status: 'compensation_failed', failedStep: step.name, errors };
}
}
return { status: 'completed' };
}
And here's our order flow using it:
TypeScript
type OrderCtx = { orderId: string; amount: number; paymentId?: string };
const orderSaga: Step<OrderCtx>[] = [
{
name: 'create-order',
execute: (ctx) => orders.create(ctx.orderId),
compensate: (ctx) => orders.cancel(ctx.orderId),
},
{
name: 'reserve-stock',
execute: (ctx) => inventory.reserve(ctx.orderId),
compensate: (ctx) => inventory.release(ctx.orderId),
},
{
name: 'charge-card',
execute: async (ctx) => {
ctx.paymentId = await payments.charge(ctx.orderId, ctx.amount);
},
compensate: (ctx) => payments.refund(ctx.paymentId!),
},
{
name: 'ship-order',
execute: (ctx) => shipping.book(ctx.orderId),
// no undo here: if this step fails, the earlier steps get undone instead
},
];
const result = await runSaga(orderSaga, { orderId: 'ord_123', amount: 4999 });
Two details in that code are easy to miss.
First, the step that failed is not undone. In our story, booking the delivery failed, so we only undo steps 1 to 3. Step 4 never finished, so the saga has no record that it needs undoing. But it may have half-worked: maybe the courier created the booking, and then the connection dropped before we heard back. Nothing will clean that up for us, so each step should clean up after itself, or at least be safe to check and retry.
Second, if an undo fails, we don't stop. Say the refund throws an error. We still release the stock and cancel the order, note the error, and finish with compensation_failed so a person can look into it. One broken undo shouldn't block the rest.
This version lives in memory, which is fine for learning. So what changes in a real system?
Where it gets tricky in real life
On a whiteboard, sagas look easy. In production, a few things make them harder than they look. Here are the ones worth knowing about.
What if a step runs twice? It will happen. Networks time out, workers crash, queues deliver a message again. If the server dies right after charging the card, a retry will charge it again. The answer is idempotency: doing something twice should have the same effect as doing it once. A good habit is to send an idempotency key to the payment provider. It's just a unique ID for the request, like order-123-charge. If the same key arrives twice, the provider sees the repeat and returns the first result instead of charging again. Make refunds safe to repeat too, and generate IDs once and reuse them.
Where does the saga remember what it did? If the orchestrator lives only in memory, a restart wipes it out and nobody knows which steps ran. Storing each saga in a database (its status, its current step, and a log of what happened) solves this.

What if an undo step fails too? Say the refund call fails. Now money is stuck with no order. Retrying with backoff is the first thing to try. If it still fails, it's safest to mark the saga as "compensation failed" and let a human know, instead of quietly calling it done.
Is a placeholder undo really harmless? It's tempting to write compensate: async () => { /* TODO */ } and move on. But then the saga cheerfully reports "compensated" while the money never came back. A dashboard that says everything is fine while the data is wrong is a tough bug to catch, because nobody goes looking for it.
Who else can see the middle? A normal transaction hides half-done work. A saga doesn't. For a moment, the stock is reserved but the payment is still pending. If another customer checks that item right then, it looks sold out, even though the first order might still fail and give the stock back. A simple defence is a status like PENDING on the record, so other flows know the data may still change.
Does the order of steps matter? Yes. Some steps are easy to undo, like reserving stock. Others can't be undone, like shipping a parcel. So put the hard-to-undo step last.
Say we ship the parcel first, and then the card payment fails. The parcel is already on the road, and we can't call it back. But if we charge the card first and shipping fails, we can simply refund.
A step you can't undo is a point of no return. Before it, if something fails, we undo. After it, we can only move forward, so we retry until it works.
What about slow steps? Some steps wait for a webhook or a timer. A good saga can pause, save itself, and wake up later. It also helps to give every step a timeout. But a timeout only means we stopped waiting, not that the other side stopped working. Say our request to the payment provider times out, but the charge went through anyway. If we retry, we must not charge twice, so the step has to be safe to retry.
Can you see what happened? It helps to give each saga one ID and put it in every log line. An event log (step started, succeeded, failed, undo started) helps too, and so does an alert for sagas that stay stuck for too long.
What we noticed in a real payments backend
We've been looking closely at a saga engine in a payments backend that talks to several outside systems. It stores every saga in Postgres and runs steps as background jobs on a queue, with retries, timeouts, and timers. A few things stood out:
- Saved state makes support easier. When every saga's status and history are in the database, "where is this order?" has a clear answer.
- Retries need real thought. If your code treats most errors as permanent, retries almost never happen. Decide clearly which errors are temporary (timeouts, network) and which are final (validation, insufficient funds).
- Placeholder undo steps are a trap. A step with a TODO as its undo makes a saga look healthy when it isn't. Every step that needs an undo should have a real, tested one before it's called done.
- Duplicates need a guard. Queues can deliver a job twice. An atomic status check or a lock, plus a unique business key per saga, keeps two workers from running the same step.
- The unhappy paths deserve tests. A step fails halfway, an undo fails, the same job arrives twice, the worker dies mid-step. That's where the surprises tend to hide.
When you don't need a saga
A saga adds moving parts, so it isn't something to reach for by default. If all your work happens in one database, use a normal transaction. If you need strict isolation, where nobody can ever see a half-done state, a saga may not fit. And if your steps are tangled together in circles, the real problem may be how you split things up.
Before you ship one
Here's a quick checklist to ask yourself:
- Does every step have a real undo?
- Is every step and every undo safe to repeat?
- Is the saga's state saved, so a crash doesn't lose it?
- What happens when an undo fails, and who gets told?
- Did you test failure in the middle and duplicate delivery?
Wrap up
A saga is a simple promise: we can't have one big commit across databases and outside systems, so we'll do small steps, and we'll have a plan for walking back. The idea, nearly 40 years old, still aligns well with modern system design.
If you're just starting, it helps to begin small: write the steps, write the undo for each one, save the state, and make every step safe to repeat. Then test the failures, not just the happy path. That gets you most of the benefit with very few surprises.
We'd love to hear from you. How does your system handle a half-finished order today?
References & Further Reading
- Sagas (Garcia-Molina and Salem, SIGMOD 1987): The original research paper that introduced the idea.
- Saga design pattern, Azure Architecture Center: Data anomalies, countermeasures, and when to use the pattern.
- Pattern: Saga, microservices.io: Chris Richardson's pattern description, with choreography and orchestration.
- Idempotent Consumer pattern, Azure Architecture Center: How to safely handle repeated messages.