An outbox poller that reads WHERE id > last_seen_id loses events in Postgres, and it takes two transactions to prove it. The first took id = 1 and committed three seconds late. The second took id = 2 and committed at once, so the poller advanced its cursor to 2 and never published the payment.captured event in row 1.
Most pages ranking for "transactional outbox pattern" explain the dual-write problem and stop there. Of the seven top results I checked, one mentions this skip, via a link. None warns that the CDC alternative can fill your disk, and none is written for payment events, where a lost message is a merchant who never hears a refund cleared. So I ran the failure modes in a throwaway Postgres 18.6 container. Unless I say otherwise, the output below is pasted from those runs.
What Is the Transactional Outbox Pattern?
When a capture succeeds, a payment service posts ledger entries and tells everything downstream: webhooks, reconciliation, the warehouse. Commit to Postgres and then publish to Kafka, and a crash between the two leaves money moved with no event. Publish first, and a rollback announces money that never moved. Kafka transactions are atomic across Kafka partitions only, so two-phase commit is off the table.
The outbox puts the event inside the same database transaction:
BEGIN;
INSERT INTO ledger_entries (account, amount) VALUES ('acc_123', 1000), ('clearing', -1000);
INSERT INTO outbox (aggregate_id, type, payload)
VALUES ('pay_8f2c', 'payment.captured', '{"amount":1000,"currency":"GBP"}');
COMMIT;
A relay then publishes committed rows. That gives at-least-once delivery, because the relay can crash after publishing and before recording progress. Every interesting decision sits in how the relay reads the table.
Why Does an Outbox Poller Skip Events in Postgres?
A sequence hands out its value when the row is inserted, but the row only becomes visible when its transaction commits. BIGSERIAL is a sequence, so id order is insert order, not commit order:
B got id=2 txid=755
--- t~1s naive poller (id > 0):
2|pay_B
A got id=1 txid=754
--- t~4s naive poller resumes from high-water mark id > 2:
(empty = pay_A skipped forever)
A transaction that does more work after its outbox insert, such as a fraud-score call before the final ledger write, can commit after a later one. The poller sees a gap at id = 1 that looks exactly like a rolled-back insert, so it cannot tell a lost event from a harmless hole. A created_at cursor has the same flaw, plus clock skew between pods.
Mature libraries still ship this bug. Marten's V9.16.1 release (18 July 2026) fixed a high-water-mark daemon that could skip events under concurrent appends. The fix checks pg_locks and in-progress transaction IDs before treating a gap as safe.
How to Fix Outbox Ordering With xid8 and pg_snapshot_xmin
PostgreSQL 13 added the 64-bit xid8 type and its functions. Stamp each row with its transaction ID and read only rows whose transaction is older than every transaction still running:
ALTER TABLE outbox ADD COLUMN txid xid8 NOT NULL DEFAULT pg_current_xact_id();
CREATE INDEX ON outbox (txid, id);
SELECT id, txid, aggregate_id, type, payload
FROM outbox
WHERE (txid, id) > ($1, $2) -- last processed (txid, id)
AND txid < pg_snapshot_xmin(pg_current_snapshot())
ORDER BY txid, id
LIMIT 500;
Every transaction with an ID below the snapshot's xmin has finished, so no row can appear behind the cursor later. In the same run, the xid8 poller returned nothing while transaction A was open (snapshot 754:756:754), then delivered pay_A and pay_B in that order. Watermill's Postgres adapter uses this design, keyed on (transaction_id, offset).
The catch is that xmin covers the whole server. I held different transactions open for two seconds and inserted an outbox row during each:
| Transaction held open | Outbox row visible to the xid8 poller? |
|---|---|
Read-only SELECT, same database | Yes |
INSERT into an unrelated table, same database | No, until it committed |
INSERT in a different database on the same server | No |
SELECT ... FOR UPDATE on an unrelated row | No |
Anything that takes a transaction ID stalls delivery, and row locks take one. A 20-minute backfill in the reporting database freezes every payment event on the instance until it finishes. I expected the same-database case; the cross-database one surprised me. Cap it with transaction_timeout (PostgreSQL 17+) and alert on max(now() - xact_start) where backend_xid IS NOT NULL.
Outbox With FOR UPDATE SKIP LOCKED: No Skips, Weaker Order
The other polling design drops the cursor and claims unpublished rows:
BEGIN;
SELECT id, aggregate_id, type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- publish each row and wait for broker acks
UPDATE outbox SET published_at = now() WHERE id = ANY($1);
COMMIT;
An uncommitted row is not there yet and gets claimed on a later poll, so nothing is skipped. You lose order instead: two relays can hold events for the same payment, and payment.refunded reaches Kafka before payment.captured. Run one relay behind an advisory lock, or assign workers by a hash of aggregate_id. The cost is bloat: Trade Republic's engineers saw dead index entries take one outbox query from 0.133 ms to 18,553 ms. They moved to PARTITION BY LIST (published_at), with unpublished rows in an indexed NULL partition and published rows truncated from the default partition.
Outbox Polling vs Debezium vs pg_logical_emit_message in 2026
| Relay | Can it skip events? | Order | Main risk | Extra infrastructure |
|---|---|---|---|---|
Poll id > cursor | Yes | Insert order | Silent loss under concurrent commits | None |
| Poll xid8 cursor | No | Transaction ID order | Any writer on the server stalls delivery | None |
Poll SKIP LOCKED + flag | No | Lost with more than one relay | Dead tuples on the hot index | None |
| Debezium on an outbox table | No | Commit order (WAL) | Slot retains WAL until the disk fills, or is invalidated | Kafka Connect, slot, wal_level=logical |
pg_logical_emit_message + Debezium | Not with transactional = true | Commit order | Slot risk, and false decodes rolled-back messages | As above, Debezium 3.0+ |
| LISTEN/NOTIFY alone | Yes, if no listener is connected | Commit order | Nothing is stored | None |
My view, as opinion: a payments team doing hundreds of events a second, not tens of thousands, should start with the xid8 poller, one relay per consumer group and NOTIFY as a wake-up between polls. It keeps total order with no new infrastructure, and its failure mode, a stall, is loud and recoverable. A skip is silent. Move to Debezium when you already run Kafka Connect or poll load shows up in pg_stat_statements. Clear Street, a US brokerage, has run Debezium off the Postgres WAL into Kafka since 2018, and its engineers wrote in 2020 that the setup had not been trouble-free.
Debezium Outbox Event Router: Table Schema and Config
Debezium 3.7.0.Final (29 September 2026) is the current stable release. Its io.debezium.transforms.outbox.EventRouter expects this table by default:
CREATE TABLE outbox (
id uuid NOT NULL PRIMARY KEY,
aggregatetype varchar(255) NOT NULL,
aggregateid varchar(255) NOT NULL,
type varchar(255) NOT NULL,
payload jsonb
);
| Setting | Default | Effect |
|---|---|---|
table.field.event.key | aggregateid | Kafka key, so one payment's events share a partition |
route.by.field | aggregatetype | Chooses the topic |
route.topic.replacement | outbox.event.${routedByValue} | aggregatetype = 'payment' goes to outbox.event.payment |
table.fields.additional.placement | unset | Copies columns to headers, e.g. currency:header |
The event id travels in a Kafka header named id, which is your dedupe key downstream. Per-payment order holds only while the partition count is fixed: the default partitioner is murmur2(key) modulo partitions, so adding partitions reshuffles keys mid-stream.
The router ignores DELETEs, so Debezium's 2019 outbox post suggested inserting and deleting the row in one transaction: the slot decodes the INSERT and the table stays empty. My run showed INSERT ... 'pay_C', then DELETE: id[bigint]:7, one commit, zero rows left.
What Happens When a Debezium Replication Slot Falls Behind?
A logical slot pins WAL until its consumer confirms it. Stop the consumer and Postgres keeps every WAL segment written since, for the whole instance. I created a test_decoding slot, left it unread and inserted one million ledger rows:
before: retained=448 bytes wal_status=reserved
after 1M ledger rows: retained=242 MB wal_status=extended
pg_wal dir size: 273M
max_wal_size was 128 MB and the slot made Postgres hold twice that. On a busy primary, that is how a stalled connector becomes a full disk. max_slot_wal_keep_size (PostgreSQL 13+, default -1, unlimited) caps it. With a 64 MB cap, more writes and a checkpoint, Postgres gave up on the slot:
LOG: invalidating obsolete replication slot "lab_td"
DETAIL: The slot's restart_lsn 0/10EA4C38 exceeds the limit by 185971656 bytes.
ERROR: can no longer access replication slot "lab_td"
DETAIL: This replication slot has been invalidated due to "wal_removed".
pg_replication_slots then showed wal_status = lost and invalidation_reason = wal_removed (a PostgreSQL 17 column). The disk survives, but the events in that window are gone from the WAL and Debezium must re-snapshot.
This is where insert-then-delete hurts: the re-snapshot finds an empty table, and every payment.captured written during the outage is lost. My position: for money events, keep the rows. Seven days in daily partitions covers most connector outages, and after a lost slot you replay by created_at while consumers dedupe on id.
- Heartbeats.
heartbeat.interval.msdefaults to0(off). WAL is shared across databases, so a quiet outbox on a busy instance pins WAL with no events flowing. - RDS and Aurora.
rds.logical_replication = 1is static and needs a reboot; on Aurora it lives in the cluster parameter group. - Failover. Before PostgreSQL 17, logical slots did not survive failover to a physical standby. Debezium's
slot.failoverneeds a 17+ primary withsync_replication_slotson the standby.
Is pg_logical_emit_message a Safe Outbox?
pg_logical_emit_message(transactional, prefix, content) writes straight into the WAL, with no table to vacuum or clean up. Debezium has decoded these messages since 1.8 (pgoutput, PostgreSQL 14+). Since 3.0, the DecodeLogicalDecodingMessageContent transform turns them into records the Outbox Event Router can handle; chain it before the router.
Watch the first argument. I emitted three messages: transactional and rolled back, non-transactional and rolled back, transactional and committed. The slot decoded this:
message: transactional: 0 prefix: outbox, sz: 52 content:{"type":"payment.captured","id":"rolled_back_NONtx"}
BEGIN 778
table public.other: INSERT: x[integer]:42
message: transactional: 1 prefix: outbox, sz: 47 content:{"type":"payment.captured","id":"committed_tx"}
COMMIT 778
The non-transactional message outlived its own rollback. Pass false in a payment path and you announce captures that never happened. Wrap the call in a helper that hard-codes true, and flag direct calls in review. With no table behind it, a lost slot leaves nothing to replay, so I would only use it for events you can regenerate from the ledger.
Can LISTEN/NOTIFY Replace an Outbox?
No. Nothing is stored for a relay that was restarting, and payloads must be under 8,000 bytes (7,999 worked; 8,000 failed with payload string too long). NOTIFY also takes an instance-wide lock at commit, which Recall.ai blamed for second-long commit queues under tens of thousands of writers in March 2025. Use it to wake the poller, not to carry events.
How Much Does an Outbox Write Cost in Postgres?
Throughput on a laptop VM shared with other containers was too noisy to rank finely, so I measured WAL, which is deterministic. Each transaction posts two ledger rows; 20,000 transactions per variant, one client, after a checkpoint:
| Variant | WAL per transaction | Extra per event |
|---|---|---|
| Ledger rows only | 380 bytes | none |
+ pg_logical_emit_message | 500 bytes | 120 bytes |
+ outbox INSERT (two indexes) | 717 bytes | 337 bytes |
+ outbox INSERT then DELETE | 792 bytes | 412 bytes |
With a 70-byte payload, the outbox nearly doubles the WAL of a small ledger write, and retained rows took about 225 bytes each on disk. With 8 clients the baseline beat every outbox variant in every round, and a plain outbox insert cost a fifth to a quarter of throughput. Replicas, slots and archived backups all carry that WAL.
Exactly-Once Delivery for Payment Webhooks and Kafka
None of these relays delivers exactly once. Kafka's idempotent producer, on by default since 3.0 (effective from 3.0.1 after a bug), dedupes retries per partition within one producer session, so a relay restart resets it. Exactly-once is built by each consumer: a processed_events (consumer, event_id) row written in the same transaction as the side effect, the inbox half of the pattern.
Merchants are consumers too. Stripe retries live-mode webhooks for up to three days with exponential backoff, does not guarantee order, and sometimes sends two Event objects for one change, so it tells you to dedupe on object ID plus event type. Adyen wants a 2xx within 10 seconds and queues failures for up to 30 days. If your outbox feeds merchant webhooks, offer the same contract: a stable event ID, a per-object version that only rises, and a published retry window. I covered the signing side in webhook signature verification and the request side in idempotency keys.
What This Means for Payments Engineers
1. Search your relay for id > or created_at > cursors. If you find one, you are losing events under concurrency today. Switch to an xid8 cursor or SKIP LOCKED claims.
2. With a cursor, cap transaction lifetimes. Set transaction_timeout and idle_in_transaction_session_timeout per application role, and alert on the oldest transaction holding an xid in any database.
3. Key every event by payment or account ID, and treat partition-count changes as migrations.
4. On CDC, bound and watch the slot. Set max_slot_wal_keep_size below your free disk, alert on retained WAL and any wal_status other than reserved, and turn on heartbeats.
5. Keep money events for seven days in daily partitions rather than insert-then-delete.
6. Ban pg_logical_emit_message(false, ...) in transactional code.
7. Make consumers idempotent on the event id, in the same transaction as the side effect. The ledger should also reject duplicate postings, as I covered in TigerBeetle vs Postgres.
Key Takeaways
An id cursor drops events whenever transactions commit out of order. An xid8 cursor fixes that, but any writing transaction on the server can stall it. Debezium swaps polling for a slot that either fills your disk or, once capped, throws events away. Pick the failure you can see.
I'm Tom Wang, and I write these guides from test output rather than vendor docs. Run the two-transaction test against your own relay before you trust it.