The transaction outbox: what it guarantees, what it does not, and what it costs
The event becomes a row in the same database, and then it shares the order's fate. The interesting part starts after that: a relay that remembers the id of the last row it sent loses events silently, and the delivery order the pattern promises is not delivered at all — neither by polling the table nor by reading the WAL.
Full technical treatment
TL;DR
- Writing to the database and publishing an event is a problem with no direct solution: between the two writes there is always a moment where the process can crash.
- The outbox makes the event a row of the same database. One commit — either both writes or neither.
- It costs +30 % on the transaction. The way people usually avoid it — a separate transaction for the event — costs twice as much, and is unsafe besides.
- The mistake that never announces itself: a relay that remembers
last_id. It loses events silently. - The pattern gives no ordering once two transactions have overlapped in time: what arrives is commit order, not insert order. Order can be kept only within a single key, and only with a single relay.
- Duplicates are unavoidable — idempotency belongs to the consumer.
The task
db.insert(order)
db.commit()
broker.publish(OrderPlaced(order.id)) # <- the process died hereThe order exists, the event does not — the consumer will never learn about it. Swap the lines: the event exists, the order does not — the consumer processes a phantom.
There is no third order.
The solution
The event becomes a row of the same database:
CREATE TABLE outbox (
id bigserial PRIMARY KEY,
payload jsonb NOT NULL,
published_at timestamptz
);db.insert(order)
db.insert(Outbox(payload={"type": "OrderPlaced", "order_id": order.id}))
db.commit() # both rows or neitherA separate process — the relay — reads the rows and sends them to the broker.
What it costs
The outbox row adds 30 % to the transaction: it travels in the same WAL and
commits with the same fsync. A separate transaction for the event costs twice
as much — two fsyncs instead of one.
Which means the unsafe way is also the slower one.
The mistake that never announces itself
The most natural relay remembers how far it got:
SELECT id, payload FROM outbox WHERE id > :last_id ORDER BY id; -- wrongbigserial issues the number at INSERT, while the row becomes visible at
COMMIT. A transaction that started earlier can commit later — and then appears
behind what has already been read. The mark has moved on, and the row is
lost for good.
No exception, no log line. The lag metric reads zero.
The right way is to remember nothing:
SELECT id, payload FROM outbox
WHERE published_at IS NULL
ORDER BY id LIMIT 100
FOR UPDATE SKIP LOCKED;SKIP LOCKED also separates two relays: the first takes rows 1–3, the second
4–6, with no overlap.
What the pattern does not give
Ordering. While transactions do not overlap in time, insert order and commit
order coincide. Let two overlap and that is that: the application inserted A,
then B, and delivery will be B, then A. Log reading gives the same. Order
survives only within a single key: put the order id into the message key, and
the events of one order arrive in sequence — as long as there is one relay,
because two relays with SKIP LOCKED overtake each other.
“Exactly once”. The process can crash between publishing and marking too. Swapping them is not an option — then the event would be lost, which is worse. So duplicates are unavoidable, and the consumer has to be idempotent:
INSERT INTO processed (event_id) VALUES (:id) ON CONFLICT DO NOTHING;
-- 0 rows inserted -> a duplicate, do not do the workInserting the id and doing the work go in one transaction. Otherwise you have rebuilt the very dual write you started from.
Two things you have to get right in production
A partial index. The relay spends almost all of its time asking “is there work” and hearing “no”. Without an index, on a 71 MB table that is 24 ms for every idle poll.
CREATE INDEX outbox_unpublished ON outbox (id) WHERE published_at IS NULL;53 microseconds instead of 24 milliseconds. And the index does not grow with the table: with an empty queue it is 8192 bytes, while the table is 71 MB.
Cleanup. DELETE does not give the space back: half a million rows delete
in 0.55 s and the table is still the same 71 MB. VACUUM returns the pages for
reuse (11 MB afterwards) but hands the operating system only the tail of the
file. For a noticeable flow: partitions by time and a DROP of the partition.
When you do not need it
If the recipient is the same database, write in one transaction. If the event can be lost without consequence, publish directly.
It is mandatory where the event causes an irreversible action — a charge, a shipment, a letter — and a divergence will not be noticed at once.
TL;DR
The task sounds innocent: record the order and tell everyone else about it. Two systems are enough to make it unsolvable directly — between the two writes there is always a moment where the process can crash.
- The outbox turns the message into a row of the same database, and then it shares the order's fate: one commit, either both or neither.
- It is cheap. Measured on PostgreSQL 16.13: the outbox row adds 30 % to the transaction, while the usual way of avoiding it — writing the event in a separate transaction — costs twice as much. The unsafe way is also the slower one here.
- The mistake that never announces itself is a relay that remembers the id of the last row it sent. It loses events silently: no exception, no log line, and a lag metric reading zero.
- The pattern does not give you ordering — not once two transactions have
overlapped in time. The promise on the pattern's canonical page —
Messages are sent to the message broker in the order they were sent by the application
— holds only while they do not overlap; let two overlap and what arrives is commit order. Neither polling nor reading the WAL changes that. - “Exactly once” does not exist. The process can crash between publishing and marking too, so idempotency belongs to the consumer, not to the sender.
A problem with no direct solution
The handler does two things: it writes the order to the database and tells the broker about it.
def place_order(request):
order = Order(total=request.total)
db.insert(order)
db.commit()
broker.publish(OrderPlaced(order.id))Between commit() and publish() the process can die. Not “in theory”: a
deploy, the OOM killer, a lost connection to the broker, a timeout. Swap the
lines and it does not get better — it gets different.
Both orders are wrong, and in different ways. A lost event is quiet: each system believes everything is fine, and you find out from a user. A phantom event is loud: the consumer reserves stock or sends a letter about an order that does not exist.
There is no third order. As long as the broker takes no part in the database's transaction, there is always a moment between the two writes.
A distributed transaction across two resources would solve this problem — but the price is a coordinator and locks held for the duration of the agreement. And for a broker it is not an option at all: Kafka does not take part in two-phase commit.
The solution: make the event a row of the same database
The solution is for the service that sends the message to first store the
message in the database as part of the transaction that updates the business
entities. A separate process then sends the messages to the message broker.
CREATE TABLE outbox (
id bigserial PRIMARY KEY,
payload jsonb NOT NULL,
published_at timestamptz
);def place_order(request):
order = Order(total=request.total)
db.insert(order)
db.insert(Outbox(payload={"type": "OrderPlaced", "order_id": order.id}))
db.commit() # both rows or neitherNotice what is not here. There is nothing specific to the pattern: this is ordinary transactional atomicity. The whole invention is that the message stopped being a message and became a row.
Next you need a second process — the relay — which reads the rows and sends them to the broker. That is where all the real questions live.
payload jsonb is not a schema, it is the absence of one
In the table above payload is a jsonb column, and that is a simplification
you pay for later. The relay neither reads nor validates its contents:
The outbox event router SMT supports arbitrary payload formats. The SMT passes on
payload column values that it reads from the outbox table without
modification.
So between producer and consumer there is not one point where an incompatible change would be noticed. It surfaces at the consumer, in production, on messages that can no longer be un-sent.
Debezium's reference shape for the table is therefore wider than three columns:
id | uuid | not null
aggregatetype | character varying(255) | not null
aggregateid | character varying(255) | not null
type | character varying(255) | not null
payload | jsonb |
Every column here carries something you would otherwise have to solve in code.
aggregatetype decides the topic name, aggregateid becomes the message key
(and therefore the partition, and therefore the order), type travels as a
header, and id is what the consumer deduplicates by: You can use this ID, for example, to remove duplicate messages
.
The difference from the bigserial in our DDL matters, and the next section
comes back to it.
On the envelope itself. The moment there is more than one field, the message value stops being a bare payload:
A representation of the outbox change event. The default structure is JSON. By
default, the Kafka message value is solely comprised of the payload value.
However, if the outbox event is configured to include additional fields, the
Kafka message value contains an envelope encapsulating both payload and the
additional fields, and each field is represented separately.
What to do about a schema change. The compatibility rules need not be invented — they are written down in the formats built for this. Avro states them as reading rules, and both directions of compatibility follow directly:
if the writer's record contains a field with a name not present in the reader's
record, the writer's value for that field is ignored.
if the reader's record schema has a field that contains a default value, and
writer's schema does not have a field with the same name, then the reader should
use the default value from its field.
The first rule covers an old reader against a new writer, the second a new reader against an old writer. Neither holds if the new field has no default: then the old message simply will not read.
Protocol buffers name the same three rules outright: Adding new fields is safe
,
Removing fields is safe
and, against them, Changing field numbers for any existing field is not safe
.
Plus one caveat that has no counterpart in a JSON schema: a removed number must
not be reused, which is what reserved is for.
The practical rule for an outbox falls out of this by itself: you cannot change
the meaning of an existing field; you can add a field that has a default; you can
remove one no consumer requires. Everything else is a new type, not a new
version of the old one. And if you want a check rather than a gentlemen's
agreement, Debezium names the way:
Using Avro can be beneficial for message format governance and for ensuring that
outbox event schemas evolve in a backwards-compatible way.
Replaying events: why it is not "just read the table again"
Sooner or later a topic has to be refilled: a consumer appeared after the events, the topic was deleted, the data was corrupted. Debezium lists the occasions outright:
You might want to perform an ad hoc snapshot after any of the following changes
occur in your Debezium environment: The connector configuration is modified to
capture a different set of tables. Kafka topics are deleted and must be rebuilt.
Data corruption occurs due to a configuration error or some other problem.
And immediately after, the sentence this section exists for:
When you initiate an ad hoc snapshot of an existing table, the connector appends
content to the topic that already exists for the table.
Appends, not replaces. The consumer gets a second copy of everything. This is not a defect of the tool: a topic has no "overwrite" operation, and cannot have one. So the whole weight of replaying falls on deduplication at the consumer — the very thing that at-least-once delivery makes mandatory, and which has a section of its own below:
Make a consumer idempotent by having it record the IDs of processed messages in
the database. When processing a message, a consumer can detect and discard
duplicates by querying the database.
And here the id bigserial in our DDL stops being good enough. Deduplication
works on a stable event identifier, and a bigserial hands out a new number
on replay — the same INSERT … ON CONFLICT DO NOTHING will let the duplicate
through as a new event. That is why Debezium's id is a uuid set on insert
rather than by a sequence. Changing this is cheaper before the first replay than
after.
A second consequence, and it contradicts the section on table growth below.
The advice to delete sent rows — DROP a partition rather than DELETE — saves
space at precisely the cost of the ability to replay. A dropped partition is
deleted history. There is one question to settle here, and better explicitly: how
far back are you prepared to replay events. The partition retention follows from
that answer, not the other way round.
As for the consumer side (reset the offset and read the topic again) — the same thing from the other end: duplicates come not only from re-sending but from re-reading, and one and the same table of processed identifiers protects against both.
What it costs
The objection to an outbox is almost always the same one: an extra write on the hot path. The objection is testable.
The outbox row adds 30 % to the transaction — it travels in the same WAL and
commits with the same fsync as the order. Meanwhile the popular way to “keep
the transaction light” — writing the event separately — costs twice as
much, because it has two commits and two fsyncs. The unsafe option is the
slower one.
This is the case where intuition points exactly the wrong way: people avoid the outbox for performance reasons and end up with the slower solution.
The relay: two ways, and not a matter of taste
Publish messages by polling the database's outbox table.
Tail the database transaction log and publish each message/event inserted into
the outbox to the message broker.
The difference is not reliability: both of them deliver everything. There are five differences, and the usual deciding one is the last — the cost of running it:
| polling the table | tailing the log | |
|---|---|---|
| works on | any SQL database | one specific database, separately for each |
| latency | no better than the polling interval | does not wait for an interval: the log pushes |
| when idle | a query per interval | nothing: the log pushes |
| table shape | needs a state column and an UPDATE | INSERT only, no state |
| operations | an ordinary process | a replication slot that grows the WAL if it falls behind |
Debezium, the main implementation of log reading, states the requirement outright:
All changes in an outbox table are expected to be INSERT operations. That is,
an outbox table functions as a queue; updates to records in an outbox table are
not allowed.
Which means the table for these two ways looks different, and moving from one to the other is not a change of process but a schema migration.
The mistake that costs the most
The most natural implementation of a polling relay remembers how far it got:
SELECT id, payload FROM outbox WHERE id > :last_id ORDER BY id;It is wrong, and it breaks not in a rare case but in the ordinary one.
The cause is that two moments in a row's life have come apart. bigserial
issues the number at INSERT — from a sequence, outside the transaction. The
row becomes visible to others at COMMIT. Between those two moments lies as
much time as the transaction takes, and in that time anyone can take a larger
number and commit sooner.
The rest is arithmetic: the relay saw row 2, raised its mark to 2, and row 1
appeared afterwards. The condition id > 2 will never find it again.
No exception, no log line. A “relay lag” metric built as max(id) - last_id
reads zero: as far as it is concerned, everything has been sent.
The cure is to give up the memory — the state moves into the row itself:
SELECT id, payload FROM outbox
WHERE published_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;The condition “not published” remembers nothing, so it finds the row again no matter how long it sat uncommitted. Log reading does not have this problem by construction: the log is a sequence of commits, and entries appear in it in the order the commits happened.
What the pattern does not give: ordering
Among the benefits on the pattern's canonical page stands this:
Messages are sent to the message broker in the order they were sent by the
application.
The promise holds while transactions do not overlap in time: then insert order and commit order coincide and there is nothing to argue about. Let two overlap and it stops being true. The experiment is exactly the one from the previous section: the application inserted A, then B, and they will be delivered B, then A — because B committed first.
Through log reading, the same. test_decoding shows the transaction carrying
row id = 2 before the transaction carrying row id = 1, and the one that
arrived first has the larger transaction number: it started later and
committed earlier. So the order is neither by row number nor by transaction
number, but by the moment of commit.
The page about the polling relay admits the same difficulty — Tricky to publish events in order
sits among its drawbacks.
The measurement sharpens what kind of difficulty it is: not one that care can
overcome, but a property of any relay on PostgreSQL.
What to do about it. You will not have a global order — you cannot lean on
one, and you must not write the consumer as if it existed. Order can be
preserved only within a single key, and only while a single relay reads the
table: the two relays with SKIP LOCKED from the section below break that too.
That is what the production implementation is built on: Debezium puts the
aggregate id into the Kafka message key — This is important for maintaining correct order in Kafka partitions
.
Events of one order land in one partition and arrive in order; events of
different orders are not ordered against each other — and should not be.
What the pattern does not give: “exactly once”
The relay does two things: it publishes and it marks. Between them, as at the very start of this article, the process can crash.
The Message relay might publish a message more than once.
You cannot swap them: then a crash between them would lose the event, which is strictly worse than a duplicate. So the choice fell on the duplicate, and it is final — at the sender's level this problem has no solution at all.
Which means idempotency has to live at the consumer. The usual way is a table of processed ids:
INSERT INTO processed (event_id) VALUES (:id) ON CONFLICT DO NOTHING;
-- 0 rows inserted -> a duplicate, do not do the workThe key point is that inserting the id and doing the work go in one transaction. Otherwise you have rebuilt the very dual write you started from.
Two relays
One relay is a single point of failure and a ceiling on throughput. Two relays on one table block each other unless you take a specific measure: the second waits for the first to release its rows.
With SKIP LOCKED, any selected rows that cannot be immediately locked are
skipped. Skipping locked rows provides an inconsistent view of the data, so
this is not suitable for general purpose work, but can be used to avoid lock
contention with multiple consumers accessing a queue-like table.
Checked: with SKIP LOCKED the first relay took rows 1–3 and the second 4–6,
with an empty intersection. Without it the second relay dies on a
lock_timeout = 300ms without taking a single row — it does not speed anything
up, it idles.
The caveat about an inconsistent view is not fine print here. It is precisely what means the order is broken once more: the second relay overtakes the first. For a queue that is acceptable exactly because we no longer have an order.
Before adding a second relay it is worth turning the cheaper knob. A batch of one gives 1,627 rows/s; a batch of a thousand, 104,656 — almost all of the difference is commits, and one process with a large batch outruns several processes with a small one. And with a single relay, order within a key is still intact.
The table that grows
Two things break an outbox in production, and both are about size.
The first is the idle poll. The relay spends nearly all of its time asking “is there work” and hearing “no”. If the queue is empty while the published rows sit in the same table, a query with no suitable index scans everything that has piled up: on a 71 MB table that is 24 milliseconds for every idle poll.
A partial index fixes this.
A partial index is an index built over a subset of a table; the subset is
defined by a conditional expression (called the predicate of the partial
index). The index contains entries only for those table rows that satisfy the
predicate.
CREATE INDEX outbox_unpublished ON outbox (id) WHERE published_at IS NULL;24 milliseconds turn into 53 microseconds. And the crucial property: the index
does not grow with the table. With an empty queue it occupies 8192 bytes — one
page, the minimum — while the table next to it is 71 MB. An ordinary index on
published_at cannot do this: it holds an entry for every row.
The second is cleanup. Published rows have to be deleted, and DELETE does
not give the space back: half a million rows delete in 0.55 s, and the table is
still the same 71 MB. VACUUM will return the pages for reuse
(0.10 s, 11 MB afterwards), but it hands the operating system only the tail of
the file.
Hence the usual advice for a noticeable flow: partitions by time and a DROP of
the partition instead of a DELETE. DROP removes the file whole — and that is
the only operation that returns space immediately.
When you do not need an outbox
The pattern solves one task: reconciling a write to the database with a message going out. If there is no such task, it is redundant.
- If the recipient is the same database, no outbox is needed: write in one transaction.
- If the message can be lost without consequence (a metric, a log line, a cache warm-up), the pattern does not pay for itself: publish directly.
- If the consumer re-reads state from the database regularly anyway, the event is only a hint to look sooner, and losing it costs latency rather than a divergence.
And one case where it is mandatory: when the event causes an irreversible action somewhere — a charge, a shipment, a letter — and a divergence will not be noticed at once.
Potentially error prone since the developer might forget to publish the
message/event after updating the database.
The drawback is named precisely: you can forget to insert the outbox row too. The difference is that a forgotten row is a mistake in one place in the code, visible in review, whereas a diverging dual write is a property of the architecture, visible nowhere.
Reproducing the numbers
Two scripts, both against a live database, both printing what PostgreSQL returned.
createdb -p 5433 outbox_bench
python3 relay.py # eight observations, not a single timing
python3 cost.py # four blocks of measurements
The scripts live in the repository's measurement directory, next to a write-up
of what came out. psycopg 3 is required; the database address comes from
OUTBOX_DSN. Section 5 of relay.py reads the WAL and needs
wal_level = logical — at any other level it says so and skips itself, while
the other seven run anywhere.
The published run: PostgreSQL 16.13, synchronous_commit = on, fsync = on,
psycopg 3.3.4, Python 3.11.15, August 2026. Everything measured here bottoms out
in fsync, so on another disk the absolute values will differ — what carries
over are the ratios within a block and the orders of magnitude.
What measured this
The numbers in this article come from these scripts. Each one opens from here, together with the record of the run: what it was measured on, what came out, and with what spread.
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- The task sounds innocent: record the order and tell everyone else about it. Two systems are enough to make it unsolvable directly — between the two writes there is always a moment where the process can crash.
- The outbox turns the message into a row of the same database, and then it shares the order's fate: one commit, either both or neither.
- It is cheap. Measured on PostgreSQL 16.13: the outbox row adds 30 % to the transaction, while the usual way of avoiding it — writing the event in a separate transaction — costs twice as much. The unsafe way is also the slower one here.
- The mistake that never announces itself is a relay that remembers the id of the last row it sent. It loses events silently: no exception, no log line, and a lag metric reading zero.
- The pattern does not give you ordering — not once two transactions have overlapped in time. The promise on the pattern's canonical page — Messages are sent to the message broker in the order they were sent by the application — holds only while they do not overlap; let two overlap and what arrives is commit order. Neither polling nor reading the WAL changes that.
- “Exactly once” does not exist. The process can crash between publishing and marking too, so idempotency belongs to the consumer, not to the sender.
In fact
- Not when the broker goes down, but when your process crashes between the two writes. An unavailable broker is handled by retries, with no outbox at all. The task the pattern solves is named precisely on its canonical page: How to atomically update the database and send messages to a message broker? Atomically — that is the word everything here is built around.
- Measured on PostgreSQL 16.13: an order with no event takes 301 µs, the same order with an outbox row in one transaction takes 392 — plus 30 %. The row travels in the same WAL and commits with the same fsync. Meanwhile the usual way around that “extra write” — putting the event in a separate transaction — costs 632 µs, twice as much, because it buys a second fsync. The unsafe option is also the slower one.
- This mistake gives you no exception, no log line and no lag in the metric — which is what makes it expensive.
bigserialissues the number atINSERTwhile the row becomes visible atCOMMIT; a transaction that took its number earlier can commit later and appear behind what has already been read. Reproduced: the mark moved to 2, and the row numbered 1 will never be sent. No exception, no log line, and amax(id) − last_idmetric reads zero. - The canonical page promises Messages are sent to the message broker in the order they were sent by the application, and that holds while transactions do not overlap in time. Let two overlap and it is over: the application inserted A, then B, and delivery is B, then A, because B committed first. Log reading gives the same, and the one that arrived first has the LARGER transaction number — it started later and committed earlier. The page about the polling relay admits the same difficulty — Tricky to publish events in order — and the measurement sharpens it into a property of any relay on PostgreSQL rather than a matter of care. Order survives only within a single key, and only with a single relay.
- Everything arrives on both paths — the difference is not reliability. It is latency (polling cannot answer sooner than its own interval; the log does not wait for one), portability (any SQL database against a solution per database), the shape of the table (Debezium requires that All changes in an outbox table are expected to be INSERT operations, so a state column has no place there) and operations: a replication slot that falls behind prevents WAL from being removed and can fill the disk.
- The process can crash between publishing to the broker and marking the row, and the pattern admits it: The Message relay might publish a message more than once. Swapping the two operations is not an option — then a crash would lose the event, which is strictly worse. The choice in favour of a duplicate is final, and idempotency belongs to the consumer: inserting the event id and doing the work go in one transaction.
- They do have to be removed, but that does not give the space back. Measured: half a million published rows delete in 0.55 s, and the table is still the same 71 MB — the rows are merely marked dead.
VACUUMreturns the pages for reuse (0.10 s, 11 MB afterwards) but hands the operating system only the tail of the file. For a noticeable flow, partitions by time and aDROPof the partition are what help.
What is covered
- A problem with no direct solution
- The solution: make the event a row of the same database
- `payload jsonb` is not a schema, it is the absence of one
- Replaying events: why it is not "just read the table again"
- What it costs
- The relay: two ways, and not a matter of taste
- The mistake that costs the most
- What the pattern does not give: ordering
- What the pattern does not give: “exactly once”
- Two relays
- The table that grows
- When you do not need an outbox
- Reproducing the numbers
- What measured this
Common misconceptions
An outbox is there so you do not lose a message when the broker goes down
Not when the broker goes down, but when your process crashes between the two writes. An unavailable broker is handled by retries, with no outbox at all. The task the pattern solves is named precisely on its canonical page: How to atomically update the database and send messages to a message broker?
Atomically — that is the word everything here is built around.
An outbox row is an extra write that slows the hot path down
Measured on PostgreSQL 16.13: an order with no event takes 301 µs, the same order with an outbox row in one transaction takes 392 — plus 30 %. The row travels in the same WAL and commits with the same fsync. Meanwhile the usual way around that “extra write” — putting the event in a separate transaction — costs 632 µs, twice as much, because it buys a second fsync. The unsafe option is also the slower one.
A relay can simply be written as WHERE id > :last_id
This mistake gives you no exception, no log line and no lag in the metric — which is what makes it expensive. bigserial issues the number at INSERT while the row becomes visible at COMMIT; a transaction that took its number earlier can commit later and appear behind what has already been read. Reproduced: the mark moved to 2, and the row numbered 1 will never be sent. No exception, no log line, and a max(id) − last_id metric reads zero.
An outbox preserves the order of events
The canonical page promises Messages are sent to the message broker in the order they were sent by the application
, and that holds while transactions do not overlap in time. Let two overlap and it is over: the application inserted A, then B, and delivery is B, then A, because B committed first. Log reading gives the same, and the one that arrived first has the LARGER transaction number — it started later and committed earlier. The page about the polling relay admits the same difficulty — Tricky to publish events in order
— and the measurement sharpens it into a property of any relay on PostgreSQL rather than a matter of care. Order survives only within a single key, and only with a single relay.
Tailing the transaction log is more reliable than polling the table
Everything arrives on both paths — the difference is not reliability. It is latency (polling cannot answer sooner than its own interval; the log does not wait for one), portability (any SQL database
against a solution per database), the shape of the table (Debezium requires that All changes in an outbox table are expected to be INSERT operations
, so a state column has no place there) and operations: a replication slot that falls behind prevents WAL from being removed and can fill the disk.
With an outbox a message is delivered exactly once
The process can crash between publishing to the broker and marking the row, and the pattern admits it: The Message relay might publish a message more than once
. Swapping the two operations is not an option — then a crash would lose the event, which is strictly worse. The choice in favour of a duplicate is final, and idempotency belongs to the consumer: inserting the event id and doing the work go in one transaction.
Published rows can simply be removed with a DELETE
They do have to be removed, but that does not give the space back. Measured: half a million published rows delete in 0.55 s, and the table is still the same 71 MB — the rows are merely marked dead. VACUUM returns the pages for reuse (0.10 s, 11 MB afterwards) but hands the operating system only the tail of the file. For a noticeable flow, partitions by time and a DROP of the partition are what help.
Check yourself
A handler writes the order, commits, then publishes the event to the broker. The process died between the commit and the publish. What happened?
Sources & further reading
9 SOURCES
- Chris Richardson — Pattern: Transactional outboxSource. The canonical description of the pattern. The problem: «How to atomically update the database and send messages to a message broker?». The solution: «The solution is for the service that sends the message to first store the message in the database as part of the transaction that updates the business entities. A separate process then sends the messages to the message broker». Among the benefits stands a claim about ordering: «Messages are sent to the message broker in the order they were sent by the application» — the one this article puts to the test. Among the drawbacks stands exactly one line — «Potentially error prone since the developer might forget to publish the message/event after updating the database», quoted at the end of this article. The possibility of a duplicate is admitted in a separate Issues section: «The Message relay might publish a message more than once».https://microservices.io/patterns/data/transactional-outbox.html
- Chris Richardson — Pattern: Polling publisherSource. Delivery by polling the table: «Publish messages by polling the database's outbox table». The single benefit listed: «Works with any SQL database». And among the drawbacks stands exactly what contradicts the ordering promise on the pattern's own page: «Tricky to publish events in order».https://microservices.io/patterns/data/polling-publisher.html
- Chris Richardson — Pattern: Transaction log tailingSource. The second way to deliver: «Tail the database transaction log and publish each message/event inserted into the outbox to the message broker». Benefits: «No 2PC», «Guaranteed to be accurate». Two of the three drawbacks matter here: «Requires database specific solutions» and «Tricky to avoid duplicate publishing»; the third is «Relatively obscure although becoming increasing common».https://microservices.io/patterns/data/transaction-log-tailing.html
- PostgreSQL 16 — SELECT, The Locking ClauseOfficial documentation. The permission that lets two relays work on one table: «With SKIP LOCKED, any selected rows that cannot be immediately locked are skipped. Skipping locked rows provides an inconsistent view of the data, so this is not suitable for general purpose work, but can be used to avoid lock contention with multiple consumers accessing a queue-like table». The caveat about an inconsistent view is not fine print here: it is the very reason this technique fits a queue and nothing else.https://www.postgresql.org/docs/16/sql-select.html
- PostgreSQL 16 — Partial IndexesOfficial documentation. The definition: «A partial index is an index built over a subset of a table; the subset is defined by a conditional expression (called the predicate of the partial index). The index contains entries only for those table rows that satisfy the predicate». And the reason it suits a queue: «One major reason for using a partial index is to avoid indexing common values».https://www.postgresql.org/docs/16/indexes-partial.html
- Debezium — Outbox Event RouterOfficial documentation. The production implementation of log reading for an outbox. The requirement on the table's shape, which is what separates this path from polling: «All changes in an outbox table are expected to be INSERT operations. That is, an outbox table functions as a queue; updates to records in an outbox table are not allowed. The SMT automatically filters out DELETE operations on an outbox table». And why the table carries a separate aggregate-id column: «The SMT uses this value as the key in the emitted outbox message. This is important for maintaining correct order in Kafka partitions».https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html
- Apache Avro 1.11.1 — Schema ResolutionOfficial documentation. The compatibility rules stated not as mode names but as reading rules. Forward: "if the writer's record contains a field with a name not present in the reader's record, the writer's value for that field is ignored." Backward: "if the reader's record schema has a field that contains a default value, and writer's schema does not have a field with the same name, then the reader should use the default value from its field." Both stop working if the new field has no default.https://avro.apache.org/docs/1.11.1/specification/
- Protocol Buffers — Language Guide (proto 3), Updating A Message TypeOfficial documentation. The same three rules, named outright: "Adding new fields is safe", "Removing fields is safe" and "Changing field numbers for any existing field is not safe." Plus a requirement with no counterpart in a JSON schema: a removed field number must not be reused — that is what `reserved` is for.https://protobuf.dev/programming-guides/proto3/
- Chris Richardson — Pattern: Idempotent ConsumerSource. The deduplication method all protection against re-sending and re-reading comes down to: "Make a consumer idempotent by having it record the IDs of processed messages in the database." Hence the requirement on the event identifier: it must be stable across runs, or a replayed duplicate passes as a new event.https://microservices.io/patterns/communication-style/idempotent-consumer.html