Two-Phase Commit
The textbook protocol for atomic commit across multiple resources: how prepare-then-commit works, why a coordinator crash can block participants indefinitely, and where 2PC is still the right answer.
Two-Phase Commit (2PC) is the classical answer to a question that will not go away: how do you make several independent resources agree to commit — or agree to abort — as a single atomic unit? A local database transaction gives you atomicity because one process controls one log and one fsync. Enroll a second database, or a database and a message broker, and there is no shared log to appeal to. 2PC manufactures one, by electing a coordinator and forcing every participant to durably promise it can commit before anyone actually does.
The protocol has a poor reputation in microservices circles, usually summarized as "2PC is blocking, don't use it." That summary is true and also too crude to design with. 2PC is not a mistake; it is a specific, well-understood trade of availability for atomicity. It is worth understanding precisely, because the pattern most people reach for instead — the saga — is not a drop-in replacement but a different consistency model with different bugs.
The problem
You need to move money between two accounts held in two separate databases. Debit succeeds in database A. Credit fails in database B. There is no ROLLBACK that spans both, so you are left holding a debit with no matching credit — money destroyed, and an auditor's phone call in your future.
The naive fix is to commit them in sequence and undo on failure: commit A, try B, and if B fails issue a compensating credit back into A. That works right up until the process crashes between the failure of B and the compensation. Now nothing undoes the debit, because the only actor that knew an undo was owed is gone. Any solution has to survive the coordinator dying at the worst possible instant — that is the actual requirement, and it is what makes distributed atomic commit hard.
The insight behind 2PC is that a resource cannot promise "I will commit" at the same moment it is asked to commit — because by then it may already have failed. So split the commit in two. First ask every participant to do all the work and reach a state where committing is guaranteed to succeed, then durably record that promise, and only then, once everyone has promised, tell them all to make it real.
That intermediate state — work done, locks held, durably promised, decision not yet known — is called prepared or in-doubt, and it is the entire pattern. It is also the source of every problem 2PC has.
When to use it
2PC is the right call when atomicity is a hard requirement and the following conditions are met:
- The resources genuinely support it. Participants must implement a real prepare phase with durable, recoverable in-doubt state — XA-compliant relational databases, transactional message brokers, JTA-managed resources. A REST API with no prepare step cannot be a 2PC participant, and bolting on a fake one is how people build systems that lose data during recovery.
- Participants are few, close, and reliable. Two or three resources in the same datacenter, on low-latency links, with high individual availability. Every enrolled participant multiplies the failure surface.
- The transaction is short. Locks are held across the whole protocol. Long-running business processes — anything measured in seconds, let alone involving a human — must not hold prepared transactions open.
- Compensation is unacceptable or impossible. Sagas require every step to have a semantically meaningful undo. Some operations have none, and for those "eventually consistent with compensation" is not a consistency model anyone will sign off on.
- You can operate the recovery path. In-doubt transactions after a coordinator crash need a resolution mechanism, whether automated recovery or a documented manual procedure. If nobody owns that runbook, do not choose 2PC.
The honest, common home for 2PC today is inside a single trust boundary: a transaction manager coordinating an application database and a JMS broker, a distributed SQL database committing across its own shards, or a Kafka transaction spanning a consume-process-produce cycle. In those settings the participants are homogeneous, co-located, and jointly operated — and 2PC does its job quietly and well.
When NOT to use it
- Across service or organizational boundaries. A 2PC participant surrenders control of when its own locks are released to a foreign coordinator. No service team should accept a dependency that can pin their rows indefinitely. This is the single strongest argument against 2PC in microservices, and it is an organizational argument as much as a technical one.
- When any participant might be slow or unreachable for long. Availability multiplies: five participants at 99.9% each yield roughly 99.5% for the transaction, before counting the coordinator. Distributed atomic commit makes systems less available by construction.
- For long-lived business processes. Order fulfillment spanning payment, inventory, and shipping over minutes is the saga's home ground. Holding prepared transactions for that long is a lock-contention disaster.
- When participants are HTTP APIs. Most services expose no prepare phase. Simulating one by "reserving" through ad-hoc endpoints means reimplementing 2PC's recovery semantics yourself, badly — at which point a saga with explicit compensation is both simpler and more honest.
- When you cannot tolerate the blocking window. If the coordinator dies after participants prepare but before the decision propagates, those participants are stuck holding locks until it recovers. If your availability budget cannot absorb that, 2PC is the wrong shape.
Architecture
The protocol has one coordinator and N participants, and runs in two phases separated by a durable decision.
sequenceDiagram
participant C as Coordinator
participant A as Participant A
participant B as Participant B
Note over C: Phase 1 — Prepare
C->>A: prepare
C->>B: prepare
A->>A: do work, hold locks, force log
B->>B: do work, hold locks, force log
A-->>C: vote YES
B-->>C: vote YES
Note over C: force COMMIT decision to log
Note over C: Phase 2 — Commit
C->>A: commit
C->>B: commit
A-->>C: ack
B-->>C: ack
Phase 1 (prepare / voting). The coordinator asks each participant to prepare. A participant does the work, holds its locks, forces a prepare record to durable storage, and votes YES — which is an irrevocable promise: whatever happens next, including my own crash, I can still commit this. Any participant may vote NO, which unilaterally aborts the whole transaction.
The decision point. Once all votes are YES, the coordinator forces a commit record to its own log. This single durable write is the moment the transaction's outcome becomes fact. Everything after it is delivery of a decision already made.
Phase 2 (commit / abort). The coordinator instructs everyone to commit. Participants apply, release locks, and acknowledge. If any vote was NO, the coordinator instead tells everyone to roll back.
Now the failure that defines the pattern's reputation:
flowchart TD
A[Participant votes YES] --> B[Enters PREPARED state<br/>locks held, work durable]
B --> C{Decision from coordinator?}
C -->|commit| D[Apply, release locks]
C -->|abort| E[Roll back, release locks]
C -->|coordinator crashed| F[IN-DOUBT: blocked<br/>locks still held]
F -->|coordinator recovers| C
F -->|manual intervention| G[Operator forces outcome<br/>— risks divergence]
A prepared participant cannot decide on its own. It promised to be able to commit, so it may not abort; it has not been told to commit, so it may not commit. It must hold its locks and wait. This is why 2PC is called a blocking protocol: coordinator failure during the decision window stalls every prepared participant, and those held locks propagate contention to unrelated transactions touching the same rows.
Two mitigations exist and neither is free. Three-phase commit adds a pre-commit round to make the protocol non-blocking under certain failure assumptions — but it assumes bounded message delay and fails under network partitions, so it is rare in practice. The mitigation that actually shipped is making the coordinator itself fault-tolerant by replicating its log via consensus (Raft or Paxos). That is what modern distributed databases do internally: 2PC for atomicity across shards, consensus for a coordinator that does not have a single point of failure. The blocking problem is solved not by replacing 2PC but by removing the single-coordinator assumption underneath it.
Code walkthrough
Real 2PC lives in transaction managers, not application code — you should be using XA or JTA, not writing this. But the protocol's shape is worth making concrete, because it shows exactly where the durability requirements sit. Start with the participant contract.
type Vote = "yes" | "no";
type Decision = "commit" | "abort";
interface Participant {
readonly id: string;
prepare(txId: string): Promise<Vote>;
commit(txId: string): Promise<void>;
abort(txId: string): Promise<void>;
}
prepare returning "yes" is the promise the whole protocol rests on. A participant that votes YES and then cannot commit — because it did not durably record the prepare, or released a lock — has broken the contract, and no amount of coordinator correctness recovers atomicity.
The coordinator needs a durable log of its own. The critical property is that the decision is persisted before it is communicated.
interface CoordinatorLog {
recordPrepared(txId: string, participantIds: string[]): Promise<void>;
recordDecision(txId: string, decision: Decision): Promise<void>;
pendingTransactions(): Promise<{ txId: string; decision?: Decision }[]>;
}
Phase 1 collects votes. A single NO, or any failure to answer, aborts:
async function collectVotes(tx: string, participants: Participant[]): Promise<Decision> {
const votes = await Promise.allSettled(
participants.map((p) => p.prepare(tx)),
);
const allYes = votes.every(
(v) => v.status === "fulfilled" && v.value === "yes",
);
return allYes ? "commit" : "abort";
}
A timeout or crash during prepare is treated as a NO — safe, because a participant that never voted YES made no promise and can abort unilaterally. The asymmetry is fundamental: abort is always safe before the decision; commit never is.
The coordinator body, with the ordering that makes recovery possible:
async function runTransaction(
txId: string,
participants: Participant[],
log: CoordinatorLog,
): Promise<Decision> {
await log.recordPrepared(txId, participants.map((p) => p.id));
const decision = await collectVotes(txId, participants);
await log.recordDecision(txId, decision);
await deliverDecision(txId, decision, participants);
return decision;
}
The await on recordDecision before deliverDecision is the load-bearing line in the entire protocol. Reverse those two and a coordinator that crashes mid-delivery wakes with no record of what it decided, while some participants have already committed — atomicity is gone permanently, with no way to reconstruct the truth.
Delivery must be retried indefinitely, because participants cannot resolve themselves:
async function deliverDecision(
txId: string,
decision: Decision,
participants: Participant[],
): Promise<void> {
await Promise.all(
participants.map((p) =>
forever(() => (decision === "commit" ? p.commit(txId) : p.abort(txId))),
),
);
}
forever — retry with backoff until success — is not defensive coding here, it is required. The decision is already fact; delivering it is an obligation, not an attempt. A participant that cannot be reached stays in-doubt until it can be.
Recovery on coordinator restart closes the loop:
async function recover(log: CoordinatorLog, resolve: (id: string) => Participant[]) {
for (const tx of await log.pendingTransactions()) {
const decision = tx.decision ?? "abort";
if (!tx.decision) await log.recordDecision(tx.txId, "abort");
await deliverDecision(tx.txId, decision, resolve(tx.txId));
}
}
The ?? "abort" is the presumed abort convention: a transaction that was prepared but has no recorded decision is aborted, because no participant can have committed without a decision existing. It is what lets a coordinator throw away logs for aborted transactions instead of remembering every transaction forever.
Performance characteristics
The figures above are indicative, chosen to convey shape rather than benchmark a specific stack.
Two phases, four durable writes. The cost is not primarily network round trips; it is forced log writes. Each participant must flush a prepare record before voting, and flush again on commit. Those are synchronous durability operations on the critical path, and they do not parallelize away — the coordinator waits for the slowest participant in each phase. A local transaction pays one fsync; a two-participant 2PC pays five including the coordinator's decision. This is why the latency multiple lands in the several-times range rather than merely double.
Lock hold time is the real cost. Locks are held from the start of prepare until the commit decision arrives — spanning two network round trips and multiple disk flushes, rather than just the local work. Under contention, throughput on hot rows collapses, because each transaction occupies them for a distributed protocol's duration instead of a local commit's. Systems that adopt 2PC and then see mysterious contention are usually seeing exactly this.
Availability multiplies downward. The transaction requires every participant and the coordinator. Independent participants at 99.9% availability give roughly 99.7% at three and 99.5% at five, before the coordinator. Adding a resource to a distributed transaction always makes it less available — which is precisely the trade being made, since it buys atomicity in exchange.
The blocking window is the tail risk. Nearly all transactions complete in milliseconds. The distribution's tail is a coordinator failure between decision and delivery, where prepared participants hold locks until recovery. Rare, and unbounded when it happens — a shape that averages hide, so this is a pattern to evaluate on p99.9 and on operational readiness, never on the mean.
Live demo
An interactive 2PC visualizer is coming soon. You will be able to step through prepare and commit across several participants, then inject failures at each point in the protocol — kill a participant before its vote, kill the coordinator immediately after it logs the decision, partition the network mid-delivery — and watch which participants end up in-doubt, which locks stay held, and how presumed-abort recovery resolves each case. Check back shortly.
Related patterns
Saga is the alternative you will actually be choosing between, and framing it as "2PC but scalable" is misleading. A saga abandons atomicity outright: it runs local transactions that each commit immediately, and undoes them with compensating transactions when a later step fails. That means intermediate states are visible — money leaves an account before the transfer completes, and another transaction can observe that. 2PC gives you isolation and atomicity at the cost of availability and lock hold time; a saga gives you availability and short local transactions at the cost of exposing intermediate states and demanding a correct compensation for every step. Choose 2PC when a partial state must never be observable and participants are few and co-located. Choose a saga when steps are long-running, participants are independently operated, or compensation is genuinely available.
Message Queue and its transactional-outbox pattern are how most teams sidestep the specific case that drove them toward 2PC in the first place: "update my database and publish an event, atomically." The outbox writes the event into the same local transaction as the state change, and a separate relay publishes it afterwards — one local commit, at-least-once delivery, no distributed transaction. When the requirement is database-plus-broker rather than database-plus-database, the outbox is almost always the better answer, and it is the reason XA transactions with brokers have largely disappeared from new systems.
Event Sourcing dissolves some 2PC cases by making the event log the single source of truth: if the only durable write is an append to one stream, there is nothing to coordinate. It does not help when two genuinely separate systems must agree, but it frequently removes the second participant that seemed to require a distributed transaction.
A note on where 2PC really lives today: it never went away, it moved down the stack. Spanner, CockroachDB, YugabyteDB and their peers all use two-phase commit for cross-shard transactions — paired with Raft-replicated participants and coordinators, which is what removes the single-coordinator blocking problem. When a distributed SQL database offers you a transaction spanning shards in different regions, you are using 2PC; it is simply being run by people who have solved the recovery problem properly. That is the modern lesson: 2PC as infrastructure is excellent, and 2PC hand-rolled across service boundaries is the thing to avoid.
Related patterns
- Alternative to: Saga
- Contrast with: Message Queue