Crash Recovery & Idempotency: Surviving Failure Mid-Transaction
Interview Question: "How do you guarantee that no money is lost or duplicated if the system crashes mid-transaction? Walk me through what happens if the database loses power, if the application server dies before responding, and if a third-party payment API times out."
The useful move here is to refuse to answer "the system crashes" as one question. There are three places a crash can land, each protected by a different mechanism, and conflating them is what makes this question hard.
power lost mid-UPDATEcommitted, but never replieddid the bank take the money?Notice that only the first is the database's problem. The other two are yours, and they are where almost all real money is lost.
Layer 1 â the database: the write-ahead logâ
A database does not write your change straight onto the data pages on disk. It first appends a record of what it intends to do to an append-only file â the write-ahead log â and flushes that to durable storage. Only then are the data pages modified, lazily, in memory, and written back later at a checkpoint.
The rule is in the name: the log is written ahead of the data. A transaction is
committed at the instant its commit record is durably in the log, not when its pages reach
disk. That is what makes COMMIT fast and still safe.
append change records to WALfsync the WALreturn success to the clientfind the last checkpointreplay the WAL forward from thereuncommitted transactions are simply never visibleA correction worth making, because the usual answer gets it wrong. The standard explanation is that recovery "finds transactions that started but never committed and rolls them back". That describes systems with undo logs, such as InnoDB. PostgreSQL has no undo pass at all.
PostgreSQL recovery is redo-only. It replays the WAL forward, which may well re-apply
changes belonging to transactions that never committed. That is harmless, because of MVCC:
every row version carries the transaction id that created it, and visibility is decided by
looking that id up in the commit log. A transaction that never committed is marked aborted,
so its row versions are invisible to everyone â they are dead tuples, and VACUUM reclaims
the space later.
So nothing is "undone". The partial work is written, and then ignored forever. Saying this is a reliable way to show you have read past the textbook paragraph.
Layer 2 â the app server: idempotency keysâ
The database committed. The money moved. Then the server died before the 200 OK reached
the phone, so the user sees "network error" and taps send again.
Nothing in the database can help here, because from its point of view the second tap is a completely legitimate new request to move more money. The fix has to make the request identifiable.
The client generates a unique key before the first attempt â once per user intent, not once per HTTP call â and sends the same key on every retry.
Idempotency-Key: 9b1deb4dINSERT INTO idempotency_keys ...COMMIT, store the responseIdempotency-Key: 9b1deb4dunique violation on INSERTreturn the stored responseCREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
user_id BIGINT NOT NULL,
request_hash TEXT NOT NULL,
state TEXT NOT NULL, -- 'in_flight' | 'done'
response_body JSONB,
transaction_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Three details that the short version of this answer skips, and that an interviewer will probe:
The key row is written in the same transaction as the effect. If you record the key first and commit separately, a crash between the two leaves a key with no transfer, and the retry is told "already done" for something that never happened. One transaction, or the guarantee is gone.
request_hash catches the key being reused with different content. A client bug â or
an attacker â that sends the same key with a different amount must be rejected loudly, not
served the cached response for a different transfer.
in_flight handles the genuinely hard case: the retry arriving while the first request
is still running. The second request hits the unique constraint but there is no stored
response yet, because the first has not committed. The correct reply is 409 Conflict with
"retry shortly" â not a success it cannot substantiate, and not a second attempt at the
transfer.
Layer 3 â third parties: there is no two-phase commitâ
The transfer involves a payment gateway. You committed locally; the gateway call timed out. Did they take the money or not?
The instinct is a distributed transaction across both. It is not available, for a blunt reason: two-phase commit requires every participant to implement prepare-and-commit and to hold locks while a coordinator decides. A third-party HTTPS API does neither, and no willingness on your side changes that.
So the guarantee is assembled from what a single-database transaction can promise. The standard construction is the transactional outbox.
UPDATE balances ...INSERT INTO outbox ...COMMITSELECT ... WHERE published_at IS NULLcall the gateway, publish the eventmark publishedThe relay can crash between sending and marking, so a message may go twice. That is the trade: at-least-once delivery with idempotent consumers, instead of exactly-once delivery that is not purchasable at any price. Which is why the gateway call carries an idempotency key of its own â the same mechanism from Layer 2, now pointed outward.
Reconciliation: the net under all threeâ
Everything above reduces the probability of divergence. None of it reaches zero, because the other side is a system you do not control and occasionally it is simply wrong.
So a sweeper runs continuously over anything stuck:
SELECT transaction_id, external_reference
FROM transactions
WHERE state = 'pending'
AND created_at < now() - INTERVAL '10 minutes'
ORDER BY created_at
LIMIT 500;
For each, it asks the provider what actually happened and moves the local record to match â
success if they have it, failed if they do not. And separately, nightly, the stronger
check: sum every balance and compare it against the ledger and the bank's own statement.
If it is out by a single paisa, alert.
That last one is not paranoia. It is the only control that catches a bug nobody anticipated, and it is the reason a double-entry journal is worth its cost â see the Zerodha schema question, where the books balancing is a single aggregate over one column rather than a script someone has to maintain.
Say this in the interviewâ
- Split the question into three layers before answering. "The system crashes" is three different failures with three different mechanisms.
- WAL is the log written before the data, and commit means the log record is durable â not that the pages are on disk.
- PostgreSQL recovery is redo-only. There is no rollback pass; uncommitted work is replayed and then rendered invisible by MVCC, and vacuumed later.
- Idempotency keys protect the retry, and the key must be written in the same transaction as the effect â plus a request hash, plus an in-flight state for the retry that arrives mid-flight.
- Two-phase commit is unavailable across third parties. An outbox plus idempotent consumers gives at-least-once, which is the strongest thing on offer.
- Reconciliation is the net, and the only control that catches the failure you did not predict.