All lessons
ArchitectureAdvanced13 min readIn my stack

Consistency: transactions, idempotency, outbox and sagas

How to keep data correct when work spans databases, queues and third-party APIs.

  • ACID
  • Idempotency
  • Outbox
  • Sagas
  • Exactly-once myths
Step 1/5
The order service saves order #7 and an "OrderCreated" row in the outbox table, in one local transaction. Both or neither.

In 30 seconds

Inside one database, transactions keep data consistent. As soon as work crosses a network boundary (a queue, a payment provider, another service), there is no single transaction any more. The tools that keep data correct are idempotency keys, the transactional outbox, sagas with compensating steps and careful reconciliation. The goal is not to avoid failures but to make every failure safe to retry.

Why I use it

  • I use these patterns because payments, orders and stock are exactly where duplicate or lost messages cost real money.
  • Networks fail in the worst way: a request can succeed while the response is lost. Idempotency makes the retry harmless.
  • The outbox pattern removes a whole class of bugs where the database says one thing and the queue says another.
  • Explaining eventual consistency honestly helps clients accept designs that are reliable rather than ones that only look simple.

How it works

01ACID and isolation levels

A database transaction groups changes so they succeed or fail together. ACID describes the promise: Atomicity (all or nothing), Consistency (constraints hold), Isolation (concurrent transactions do not see each other's half-done work) and Durability (committed data survives a crash). Isolation comes in levels. Read committed, the PostgreSQL default, only shows committed data but lets a value change between two reads in the same transaction. Repeatable read gives a stable snapshot, which is the MySQL InnoDB default. Serializable makes concurrent transactions behave as if they ran one after another, at the cost of retries when conflicts occur. Classic anomalies are lost updates (two users decrement stock at once) and write skew. Prevent them with row locks (SELECT ... FOR UPDATE), atomic updates (SET stock = stock - 1 WHERE stock > 0), unique constraints or serializable isolation with retry.

02Why distributed transactions are hard

When an order touches your database, a payment provider and a message queue, no single transaction covers all three. Two-phase commit (2PC) tries: a coordinator asks every participant to prepare, then to commit. It works in some closed systems, but it blocks when the coordinator fails, holds locks across network round-trips, and is unsupported by most queues and every third-party HTTP API. The fundamental problem is the dual write: you save an order, then publish 'order created'. If the process crashes between the two, either the event is lost or, if you publish first, an event exists for an order that was never saved. Retrying adds another problem: a timeout does not tell you whether the other side did the work. You cannot remove these failures, so you design for them.

  • A timeout means 'unknown', not 'failed'.
  • Every cross-boundary step must be retryable without side effects.
  • Keep a reconciliation path for states you cannot determine automatically.

03Idempotency keys

An operation is idempotent when doing it twice has the same effect as doing it once. GET, PUT and DELETE are idempotent by definition; POST, which creates things or charges cards, is not. The fix is an idempotency key: the client generates a unique ID (a UUID) per logical operation and sends it, typically in an Idempotency-Key header. The server stores the key with the result in the same transaction as the work, protected by a unique constraint scoped to the tenant or user. A retry with the same key returns the stored result instead of repeating the action; a concurrent duplicate hits the unique constraint and waits or fails cleanly. Keys should expire after a sensible window, and a key reused with a different request body should be rejected. Apply the same idea to consumers: record processed message IDs.

sql
CREATE TABLE idempotency_keys (
  tenant_id     uuid        NOT NULL,
  key           text        NOT NULL,
  request_hash  text        NOT NULL,
  response      jsonb,
  created_at    timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, key)
);
 
-- Inside the same transaction as the business write:
INSERT INTO idempotency_keys (tenant_id, key, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, key) DO NOTHING
RETURNING key;
-- No row returned? It is a retry: load and return the stored response.

04The transactional outbox

The outbox pattern solves the dual write. Instead of publishing to the broker directly, you insert the event into an outbox table in the same database transaction as the business change. Either both are committed or neither is. A separate relay process then reads unpublished rows, publishes them to the queue or broker, and marks them as sent. If the relay crashes after publishing but before marking, the event is published again, so delivery is at-least-once and consumers must be idempotent. Use FOR UPDATE SKIP LOCKED so several relay workers can share the work without collisions, and clean up old published rows. An alternative relay is change data capture (for example, Debezium reading the database log), which avoids polling. The inverse pattern, the inbox, stores incoming message IDs to deduplicate on the consumer side.

sql
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = $1;
INSERT INTO outbox (aggregate_id, type, payload)
VALUES ($1, 'order.paid', $2);
COMMIT;
 
-- Relay worker, in a loop:
SELECT id, type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- publish each row, then:
UPDATE outbox SET published_at = now() WHERE id = ANY($1);

05Sagas: orchestration vs choreography

A saga is a long business transaction broken into local steps, each with a compensating action that semantically undoes it. Placing an order might be: reserve stock, charge the card, create the shipment. If charging fails, the saga releases the stock. Compensation is not rollback: a sent email cannot be unsent, so you send a correction; a captured payment is refunded, not erased. In choreography, each service listens for events and emits the next one ('StockReserved' triggers payment). It is loosely coupled but hard to follow once there are many steps. In orchestration, one component, an explicit state machine, tells each participant what to do and records progress. It is easier to monitor, test and change, and it is my default for flows with more than two or three steps. Either way, persist saga state, make every step idempotent and expose stuck sagas to operators.

06Eventual consistency, exactly-once myths and CAP

Eventual consistency means that, if no new changes arrive, all copies of the data will converge, but for a short time they may disagree. In practice this is fine for loyalty points appearing a second later; it is not fine for showing a balance the user can overspend. Design the user experience for it: show 'processing' states, read your own writes from the primary, and reconcile regularly against the source of truth, such as a payment provider's report. 'Exactly-once delivery' over a network is a myth; what you can build is exactly-once effect, from at-least-once delivery plus idempotent processing. CAP, in plain words: when the network splits your system in two, each side must either refuse some requests (choose consistency) or keep answering with possibly stale data (choose availability). Without a partition, the real trade-off is latency versus consistency.

Use it when

  • Any workflow that writes to a database and publishes a message or calls an external API.
  • Payments, refunds, orders and stock movements, where duplicates or losses cost money.
  • Public or partner APIs that clients may retry after timeouts.
  • Multi-step business processes across services or third parties.

Skip it when

  • Work that fits in one database transaction; use the transaction and keep it simple.
  • A full saga framework for a two-step flow that an outbox and an idempotent handler already cover.
  • Distributed two-phase commit across HTTP services; it does not fit and causes blocking.

Alternatives

  • Single database transaction

    The simplest and strongest option when all data lives in one database.

  • Change data capture

    Stream the database log (for example with Debezium) instead of polling an outbox table.

  • Workflow engines

    Temporal and similar tools persist saga state and retries for long workflows.

  • Two-phase commit

    Possible between supporting databases in closed systems; rarely worth it for web apps.

Best practices

  1. Treat a timeout as an unknown outcome and resolve it by checking, not by guessing.
  2. Require idempotency keys on every non-idempotent public endpoint.
  3. Publish events through an outbox written in the same transaction as the change.
  4. Make every consumer idempotent with processed-ID tables, unique constraints or upserts.
  5. Prefer orchestration for multi-step sagas, with persisted state and visible stuck flows.
  6. Run scheduled reconciliation against the source of truth for money and stock.

Common mistakes

  • Publishing to a queue directly after a commit and losing events on crashes.
  • Retrying payment requests without idempotency keys and charging customers twice.
  • Marking an operation 'failed' after a timeout when it actually succeeded.
  • Assuming a broker's 'exactly-once' setting covers database writes and emails.
  • Writing compensations that are not idempotent themselves, so retries refund twice.

Deep dive

07MVCC, serializable isolation and retry loops

PostgreSQL and MySQL InnoDB use multi-version concurrency control: writers create new row versions and readers see a snapshot, so reads do not block writes. That is why anomalies appear: two transactions can each read a snapshot, decide, and write without seeing each other. PostgreSQL's serializable level uses Serializable Snapshot Isolation, which tracks read-write dependencies and aborts one transaction with SQLSTATE 40001 (serialization_failure) when a dangerous pattern forms. It gives the strongest guarantee without locking every row, but only if the application retries the whole transaction on 40001, ideally with a small backoff and a retry cap. Keep serializable transactions short, avoid external calls inside them, and use it for invariants that span rows, such as 'at least one doctor on call', where a simple row lock is not enough.

ts
async function withSerializableRetry<T>(fn: (tx: Tx) => Promise<T>, attempts = 5): Promise<T> {
  for (let i = 1; ; i++) {
    try {
      return await db.transaction(fn, { isolationLevel: "serializable" });
    } catch (err: any) {
      const retryable = err?.code === "40001" || err?.code === "40P01"; // serialization / deadlock
      if (!retryable || i >= attempts) throw err;
      await new Promise((r) => setTimeout(r, Math.random() * 50 * i));
    }
  }
}

08Idempotency in depth: in-flight requests and fingerprints

A naive idempotency table has a gap: the first request is still running when the retry arrives. Store a state with the key: in_progress, then completed with the response, or failed with a retryable flag. A retry that finds in_progress gets 409 Conflict (or waits briefly), never a second execution. Fingerprint the request (method, path, a canonical hash of the body) and reject reuse of a key with a different fingerprint. Decide what to store: final success and deterministic validation errors are replayed; transient errors such as a downstream timeout are not cached, so the client can retry. Scope keys by account to avoid collisions, expire them after the client's retry window, and when the operation calls a third party, pass a derived idempotency key downstream, so the whole chain is safe end to end.

  • States: in_progress, completed (with response), failed.
  • Same key + different body = 422, not a replay.
  • Forward a derived key to payment providers and other APIs.

09Outbox at scale: ordering, CDC and replication slots

A polling relay with SKIP LOCKED is simple but can reorder events, because several workers publish in parallel. If consumers need order per entity, partition the work by aggregate ID or use a single relay per partition, and include an aggregate version in each event so consumers can discard stale ones. Change data capture tools such as Debezium read the write-ahead log through a logical replication slot instead of polling, giving low latency and commit order. The operational trap: a replication slot keeps WAL on the primary until the consumer confirms it. If the CDC connector stops, WAL accumulates and can fill the disk. Monitor slot lag, set max_slot_wal_keep_size (PostgreSQL 13+) as a safety cap, and drop abandoned slots. Prune the outbox table regularly, ideally by partition, so it stays small and fast.

sql
-- How much WAL each replication slot is holding back
SELECT slot_name, active,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;
 
-- postgresql.conf: never let a stuck slot fill the disk
-- max_slot_wal_keep_size = '20GB'

10Saga design: pivot steps and countermeasures for isolation

Sagas give up isolation: other transactions can see intermediate states, like reserved stock for an order that will be cancelled. Order steps deliberately. Compensatable steps come first (reserve stock, place a hold), then the pivot step, the point of no return such as capturing payment, then retriable steps that must eventually succeed (create shipment, send receipt) and are retried rather than compensated. To limit anomalies, use countermeasures. A semantic lock marks records as pending so other flows treat them carefully. Commutative updates (add and subtract balances) make order irrelevant. Rereading a value before an update detects that it changed. Version fields reject stale writes. Persist saga state with timeouts per step, so a saga waiting forever on a lost reply becomes a visible, actionable item rather than a silent leak of reserved stock.

Real-world scenarios

01

Payment gateway times out during checkout

Problem

The app calls the gateway to capture a payment, and after 30 seconds the request times out. The customer sees an error, retries and is charged twice.

How to solve it

Treat the timeout as unknown. Before calling the gateway, store the payment attempt with status pending and a generated idempotency key, and send that key with the request. On timeout, keep the status as outcome_unknown and show the customer 'payment is being confirmed'. A background job queries the gateway by that key or reference and resolves the state; a retry with the same key cannot charge twice. Disable the pay button while a payment is pending, and run a daily reconciliation against the gateway's settlement report for anything left unresolved.

02

Events lost during deploys

Problem

Warehouses occasionally never hear about paid orders. The code commits the order, then publishes to the queue, and losses line up with deploy restarts.

How to solve it

It is the dual write. Insert an outbox row in the same transaction as the status change, and have a relay publish and mark rows. On the receiving side, add an inbox table with a unique message ID so redeliveries are ignored. Backfill missed events by comparing paid orders with warehouse acknowledgements over the affected period. Configure graceful shutdown so workers finish the current batch before exiting, and alert on outbox rows older than a few minutes, which signals a stuck relay.

03

Overselling during a flash sale

Problem

A limited product sold 130 units when only 100 existed. The code reads stock, checks it in application code, then writes the new value.

How to solve it

Replace read-check-write with one atomic statement: UPDATE products SET stock = stock - $2 WHERE id = $1 AND stock >= $2, and treat zero affected rows as sold out. For checkout flows that take minutes, create reservations with an expiry inside the same transaction and release them via a scheduled job if payment does not complete. Under very high contention on one row, consider splitting stock into several counters or a queue that serialises orders for that item. Add a test that runs concurrent purchases and asserts stock never goes negative.

Cheat sheet

Atomic stock decrement
UPDATE products SET stock = stock - 1 WHERE id = $1 AND stock > 0
Lock a row for the transaction
SELECT ... FROM accounts WHERE id = $1 FOR UPDATE
Parallel relay workers
SELECT ... FROM outbox WHERE published_at IS NULL LIMIT 100 FOR UPDATE SKIP LOCKED
Insert-once dedupe
INSERT INTO inbox (message_id) VALUES ($1) ON CONFLICT DO NOTHING
Retry serializable conflicts
Retry whole transaction on SQLSTATE 40001 / 40P01
Idempotency header
Idempotency-Key: 7f1c2d9e-3b4a-4c55-9a77-1e2f3a4b5c6d
Find stuck CDC slots
SELECT slot_name, active FROM pg_replication_slots;
Cap WAL kept by slots
max_slot_wal_keep_size = '20GB'
Saga step order
Compensatable steps -> pivot -> retriable steps

In my real projects

  • In the POS SaaS, domain events go through a transactional outbox into BullMQ and then to handlers, and outgoing webhooks are HMAC-signed and sent via a queue.
  • Partner orders there use idempotency keys, and offline sync commands from branch tills are deduplicated per terminal.
  • Its refunds have an explicit 'outcome unknown' state and a reconciliation job, instead of guessing after a gateway timeout.
  • In the gym CRM, SMS sending retries only on connection failure so no message is sent twice, and the pet-shop app retries only idempotent HTTP methods.

Questions & answers

Q1What is the transactional outbox pattern and why use it?

It solves the dual-write problem: saving data and publishing a message are two systems, so a crash between them leaves them inconsistent. With an outbox, you insert the event into a table in the same database transaction as the business change, so both are committed together or not at all. A relay process then reads unpublished rows, sends them to the broker and marks them published. If it crashes mid-way, it republishes, so delivery is at-least-once and consumers must be idempotent. The result is that no event is ever lost and none is published for data that was rolled back.

Q2How do idempotency keys work in a payments API?

The client generates a unique key per logical operation, such as one checkout attempt, and sends it with the request. The server stores the key, a hash of the request and the result, in the same transaction as the work, with a unique constraint scoped to the account. If the client times out and retries with the same key, the server returns the stored result instead of charging again. A concurrent duplicate collides on the constraint. Reusing the key with a different body is rejected. Keys expire after a window, often around a day, depending on the retry policy.

Q3Orchestration or choreography for sagas?

Choreography works for short flows: each service reacts to an event and emits the next, with no central component and loose coupling. As steps grow, the flow becomes implicit and spread across services, so nobody can see where an order is stuck or reason about compensations. Orchestration puts the flow in one explicit state machine that commands each step, persists progress and triggers compensations. It is easier to test, monitor and change, at the cost of one more component that knows the whole process. For anything beyond two or three steps, especially with money involved, I prefer orchestration.

Q4Is exactly-once delivery possible?

Not as a general network guarantee. A sender cannot know whether a lost acknowledgement means the message was not processed or only the reply was lost, so it must either risk losing it or risk sending it again. Systems choose at-least-once and then make processing idempotent, which gives exactly-once effect. Kafka's exactly-once semantics are real but scoped to reading and writing Kafka topics transactionally. As soon as a handler touches a database, an email provider or a payment API, you need deduplication at that boundary: unique keys, upserts or a processed-messages table.

Q5Explain the CAP theorem in practical terms.

CAP says that during a network partition, when parts of a distributed system cannot talk to each other, you must choose between consistency and availability. Choosing consistency means some requests are refused or wait until nodes agree, so nobody reads stale data. Choosing availability means every node keeps answering, possibly with outdated data, and conflicts are reconciled later. Partitions are not optional in real networks, so the choice is really what to do when they happen. Many teams find PACELC more useful: even without partitions, you trade latency against consistency, for example with synchronous versus asynchronous replication.

Q6How do you prevent two users from buying the last item in stock?

Make the decision atomic in the database. The simplest version is a conditional update: decrement stock where the quantity is still positive, and check the number of affected rows; zero means sold out. Alternatively, lock the row with SELECT FOR UPDATE inside a transaction, or keep a reservations table with constraints. For high-contention items, short-lived reservations with expiry work well: the item is held during checkout and released if payment does not complete. Never check stock in application code and then update in a separate step without a lock; that is the classic race condition.

Senior-level questions

S1When would you introduce a workflow engine instead of hand-written sagas?

When workflows become long-running, numerous or hard to observe. A hand-written orchestrator is fine for a few flows with a handful of steps: a state table, an outbox and idempotent handlers. Once you need timers lasting days, human approvals, versioned workflow definitions, retries with policies per step and a UI to inspect stuck instances, you are rebuilding a workflow engine. Tools like Temporal persist the workflow's event history and replay code deterministically, so a crash resumes exactly where it stopped. The trade-offs: a new piece of critical infrastructure to run or pay for, a programming model with determinism rules, and a learning curve. I introduce one when the business logic, not the plumbing, becomes the larger part of the code.

S2How do you prove a money-moving system is correct over time?

Design for verification, not just for correctness. Use a double-entry ledger: every movement is an immutable pair of entries that sum to zero, so balances are derived, never edited in place. Make every external operation carry a reference you can match against the provider. Run reconciliation at several levels: internal invariants (ledger sums to zero, no negative balances), and external matches against gateway and bank reports, producing a list of breaks with owners. Keep amounts in integer minor units with explicit currencies. Alert on unreconciled items by age and value. The trade-off is more tables and jobs than a simple balance column, but it turns 'we think it is right' into evidence that auditors and finance teams accept.

S3Strong or eventual consistency for a feature: how do you decide with the business?

I frame it as the cost of being briefly wrong against the cost of being slower or less available. For each piece of data I ask what happens if a user sees a value that is a few seconds old. Loyalty points, analytics and search results: harmless, so eventual consistency with asynchronous updates is fine and scales cheaply. Account balances, seat reservations, stock at checkout: a stale read can cause overspending or double booking, so the decision point must read from the primary, under a lock or a conditional write. Often the answer is mixed: browse with eventually consistent data, then validate strongly at the moment of commitment. Writing that per-feature decision down avoids accidental guarantees nobody agreed to.

Glossary

Dual write
Writing to two systems without a shared transaction, so a crash can leave them inconsistent.
Idempotency key
A client-generated ID that lets the server recognise and safely replay a retried request.
Outbox
A table of events written in the business transaction and published later by a relay.
Inbox
A table of processed message IDs used by a consumer to ignore duplicates.
Compensating action
A business step that semantically undoes an earlier one, such as a refund.
Pivot transaction
The saga step after which the flow can no longer be compensated, only completed.
MVCC
Keeping several row versions so readers see a consistent snapshot without blocking writers.
Write skew
Two transactions read overlapping data and write different rows, together breaking a rule.
Reconciliation
Periodically comparing your records with the source of truth and fixing differences.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.