Two-phase commit is not dead, it is just expensive and you should know the price

An order flow writes to a database and publishes to a message broker. Both must happen or neither, because an order that exists with no downstream event is a lost fulfilment and an event with no order is a phantom shipment.

Someone reaches for XA transactions. It works in staging. It works in production for two months.

Then the transaction coordinator’s host is terminated during a node rotation, thirty seconds after it collected votes from both participants and before it wrote its decision. The database has rows locked in a prepared state. The broker has messages held. Neither can do anything about it, because both promised they could commit and neither has been told whether to.

Those locks are held until a human resolves them. Everything touching those rows queues behind them.

The protocol, and where the promise lives

Two-phase commit does exactly what its name says.

In the prepare phase, the coordinator asks each participant whether it can commit. Each participant does all the work, acquires the locks, writes the change durably in a prepared state, and votes. A yes vote is a binding promise: I can commit this later, whatever happens to me in the meantime.

In the commit phase, if every vote was yes, the coordinator records its decision durably and tells everyone to commit. Any no vote produces an abort.

sequenceDiagram
    participant C as Coordinator
    participant DB as Database
    participant MQ as Broker

    C->>DB: prepare
    DB->>DB: do work, lock rows, write prepared
    DB-->>C: yes (binding promise)
    C->>MQ: prepare
    MQ->>MQ: stage message, write prepared
    MQ-->>C: yes (binding promise)
    Note over DB,MQ: Both now hold locks and<br/>cannot decide anything alone
    C->>C: write commit decision durably
    C->>DB: commit
    C->>MQ: commit

That prepared state is what makes atomicity possible and it is also the entire problem. Between the vote and the decision, a participant has given up its autonomy. It cannot abort, because it promised. It cannot commit, because it has not been told to. It can only wait, holding locks.

The window is the cost, not the latency

The usual complaint about 2PC is that it is slow, and the round trips are real: two phases across every participant, plus durable writes at each step.

Latency is the smaller problem. The larger one is how long locks are held and what happens if the coordinator disappears during that window.

If the coordinator crashes after collecting votes and before broadcasting, participants are stuck indefinitely. This is not a bug in a particular implementation, it is a proven property: no protocol can be both non-blocking and safe when the coordinator can fail at an arbitrary point and messages can be delayed. Three-phase commit reduces the window by adding a pre-commit round and does not eliminate it, which is why it is discussed more than deployed.

The practical consequence is that the coordinator is a single point of failure whose failure is unusually expensive. It has to be highly available, and its transaction log has to be durable, because recovery depends on reading what it decided.

Availability multiplies downward

The arithmetic here is the thing I would want anyone to take away, because it is counterintuitive and it decides most architectures.

A transaction spanning N participants succeeds only if all N are available. Availability multiplies:

3 participants at 99.9% each  ->  0.999^3  =  99.7%
5 participants at 99.9% each  ->  0.999^5  =  99.5%

Going from three participants to five takes you from roughly 26 hours of expected annual downtime to 44, without anything getting less reliable individually. Each service you add to the transaction makes the transaction less likely to complete.

That is the opposite of what people expect from adding redundancy, and it is why 2PC scales badly across a microservice estate. It also assumes the participants fail independently, which they often do not, since they frequently share a network, a cluster or a deployment pipeline.

The database version people already use

XA gets dismissed while a version of the same protocol runs happily inside most databases.

Postgres has prepared transactions:

BEGIN;
INSERT INTO orders (id, total) VALUES (1, 100);
PREPARE TRANSACTION 'order-1';    -- prepared. locks held. survives restart.

-- later, from anywhere
COMMIT PREPARED 'order-1';

max_prepared_transactions defaults to zero specifically because a forgotten prepared transaction holds locks forever and blocks vacuum, which eventually threatens transaction id wraparound. That default is the database saying this feature is dangerous unless you are running a coordinator that will clean up after itself.

Synchronous replication is a close cousin: a commit waits for a replica to acknowledge before returning, which is a distributed agreement with the same latency-for-safety trade, discussed in the context of fsync and where durability actually comes from.

The outbox pattern, which is what most people actually want

For the database-plus-broker case that sends people to XA, there is a simpler answer that removes the distributed transaction entirely.

BEGIN;
INSERT INTO orders (id, total) VALUES (1, 100);
INSERT INTO outbox (id, topic, payload)
VALUES (gen_random_uuid(), 'orders', '{"orderId":1}');
COMMIT;    -- one database, one local transaction, genuinely atomic

A separate process reads the outbox and publishes to the broker, marking rows as sent. Publishing may happen more than once if the process crashes after publishing and before marking, which makes this at-least-once, and that is fine because consumers should be idempotent anyway.

What this buys is that the atomic part is a single local transaction, which every database does well, and the distributed part is retryable rather than blocking. There is no prepared state, no coordinator, and no participant sitting on locks waiting to be told what to do. The cost is a small delay between the commit and the publish, which is observable as outbox lag rather than being an invisible risk.

I reach for this by default and I would not call it strictly better in every case, because it genuinely gives up atomicity for a window. It gives it up in a way you can measure, which is usually the trade worth making.

Where I would still use 2PC

A small number of transactional resource managers inside one operational boundary, where the correctness requirement is real and a window of inconsistency is genuinely unacceptable. Two databases owned by the same team, in the same cluster, with a coordinator you run and monitor.

Financial settlement between internal ledgers is the case where the argument usually holds, because a temporary inconsistency between two ledgers is not something you can reconcile after the fact without an audit.

What makes it wrong across service boundaries is not the protocol. It is that a participant’s autonomy is the thing being surrendered, and asking another team’s service to hold locks pending your coordinator’s decision is asking them to accept your availability as a ceiling on theirs. Most organisations discover that the hard way, and the alternative they arrive at is a saga, which trades atomicity for the ability of every participant to decide for itself.

Frequently Asked Questions

How does two-phase commit work?
A coordinator asks every participant whether it can commit, and each participant does all the work, takes the necessary locks, writes the changes durably in a prepared state, and votes yes or no. If every vote is yes the coordinator sends a commit instruction and each participant makes its changes visible. If any vote is no, the coordinator sends an abort and everyone rolls back. The atomicity comes from participants promising during the prepare phase that they will be able to commit later regardless of what happens to them in between.
Why is two-phase commit considered blocking?
Because a participant that has voted yes cannot decide anything on its own. It has promised to commit if asked, so it cannot roll back, and it has not been told to commit, so it cannot release its locks. If the coordinator crashes after collecting votes but before broadcasting the decision, every participant sits holding locks indefinitely, blocking any other transaction that touches those rows, until an operator or a recovery process resolves it manually.
When is two-phase commit still the right choice?
When the participants are a small number of transactional resource managers inside one trust and operations boundary, and the correctness requirement genuinely cannot tolerate a window of inconsistency. Writing to a database and publishing to a message broker in the same logical operation is the classic case, and even there the outbox pattern usually achieves the same outcome with better failure characteristics. Across service boundaries owned by different teams it is almost always the wrong tool.

Sources

[ RELATED_LOGS ]

TTFB: -- ms LOAD: -- s PAYLOAD: -- kb