You Don't Need an Outbox¶
The outbox pattern has quietly become canonical in microservice tutorials. It has a name, library support, conference talks, and a steady stream of blog posts that walk through implementing it. It's blessed. And yet, every time it shows up in a system, it's a sign that something else has gone wrong upstream.
The pattern itself is clever. It works around a real, structural problem: two systems that need to agree but can't share a transaction. The question worth asking is whether you need to have that problem in the first place. Once you take that seriously, the outbox stops looking like a pattern and starts looking like an admission of defeat.
The Problem the Outbox Solves¶
Picture a typical service. It receives a command, writes some rows to its relational database, and publishes a domain event to a message broker so that other services can react. Two side effects, two systems, one logical operation.
What happens if the database commit succeeds but the broker write fails? You've changed state without telling anyone. The order is placed, but no OrderPlaced ever arrives downstream. The other direction is worse: you announce an event for state that the database eventually rolled back. Either way produces silent inconsistency, and debugging it requires reconstructing the timing of two systems that don't share a clock.
This is the dual-write problem. The outbox pattern is the standard solution. Inside the same transaction that writes the business state, you write a row into a local outbox table. A background process, sometimes called a relay or forwarder, polls or tails that table, sends each entry to the broker, and marks it as delivered.
Because the outbox row and the business state live in the same transaction, they live or die together. If the broker is down, no message is lost; the row simply waits. If the transaction rolls back, the row was never there to send. The dual-write becomes a single write followed by an asynchronous handoff. Done well, this gives you at-least-once delivery with transactional consistency to the database.
What the Outbox Actually Costs¶
If you've ever run an outbox in anger, you know it's not free.
Latency is the first cost. Events don't appear downstream when the transaction commits. They appear when the relay next polls the outbox, picks them up, and writes them to the broker. For high-throughput systems you crank polling intervals down, accept higher database load, and watch tail latency rise anyway.
Monitoring surface is the second. Outbox lag is a metric you didn't have before. So is broker write failure rate, dead-letter handling, and the inevitable runbook for "the outbox is backed up, what do we do?" The pattern produces its own operational ecosystem of dashboards, alerts, and on-call playbooks.
Ordering is the third, and it's the trap people fall into late. The naive outbox relay picks up rows in order and sends them in order. The moment you scale it horizontally for throughput, ordering across partitions is no longer guaranteed. You can shard by aggregate ID and preserve per-aggregate order, but the system-wide order that some consumers were quietly relying on is gone. Most teams discover this after a downstream service starts producing nonsense because it assumed a global order that never existed.
Schema duplication is the fourth. You now have at least two representations of every event: one in the outbox table, probably a JSON blob alongside columns for status, retries, and timestamps, and one on the wire, whatever Avro, Protobuf, or JSON schema the broker speaks. They must stay in sync, version compatibly, and roll back compatibly. Two places to change. Two places to break.
Idempotency is the fifth, and it's the one that always survives the rewrite. Outbox or not, your consumers must handle duplicates. The relay can deliver the same event twice if the network fails between sending and marking-as-delivered. The broker can deliver the same event twice. The consumer can process the same event twice. None of this changes, as we discussed in Exactly Once Is a Lie. The outbox doesn't remove the requirement. It just gives you another place to manage it.
The Hidden Assumption¶
The outbox pattern is a sensible answer to a specific question: how do I keep my CRUD database and my message broker in agreement?
That question contains the assumption. It assumes that business state lives in one system and events live in another, that the database is the source of truth and the broker is a side channel for telling other services what changed. Events, in this worldview, are second-class. They're notifications about state, not the state itself.
This is the CRUD mindset bolted onto event-driven semantics. You keep the relational model, you keep the broker, and you tape over the seam between them with an outbox. It works. But the seam exists because you chose to have two systems in the first place. As we argued in It Was Never About the Database, the framing decides what problems you end up solving.
What If the Event Is the State?¶
This is where Event Sourcing changes the question entirely. In an event-sourced system, the event is not a notification about state. It is the state. Appending an event is the persistence step. There is no separate row to write, no separate transaction to coordinate, no broker to keep in sync.
A consumer that wants to know about an event doesn't subscribe to a broker. It observes the event store directly. The store is already the durable, ordered, append-only log that the broker was trying to be, except it's also the database. One write. One source. No dual write to reconcile.
Put differently, the outbox exists to build a log on top of a database. Event Sourcing skips the intermediate step: the log is the database. Once you accept that, the entire scaffolding of outbox tables, relay processes, dead-letter queues, and lag monitors stops being something to maintain. It stops being something to have.
What Subscribers Actually Look Like¶
In EventSourcingDB, a subscriber doesn't connect to Kafka or RabbitMQ. It opens a long-lived HTTP connection to /api/v1/observe-events and starts reading. The server streams historical events first, then new events as they arrive, on the same connection.
curl \
-i \
-X POST \
-H "authorization: Bearer <API_TOKEN>" \
-H "content-type: application/json" \
-d "{
\"subject\": \"/orders\",
\"options\": {
\"recursive\": true
}
}" \
http://localhost:3000/api/v1/observe-events
That's the entire subscription. No broker. No consumer group. No offset commit topic. No outbox table. The connection emits NDJSON: one event per line, plus periodic heartbeats so intermediate proxies don't drop the connection during quiet periods.
When the subscriber wants to resume after a restart, it remembers the ID of the last event it processed and sends a lowerBound on reconnect:
{
"subject": "/orders",
"options": {
"recursive": true,
"lowerBound": {
"id": "2010",
"type": "exclusive"
}
}
}
Because event IDs are globally ordered and gap-free, the subscriber knows exactly where it left off. There is no offset translation, no broker-specific position object, no need for a separate cursor store coupled to consumer groups. The position is a number, and the number is a property of the event in the store.
Different subscribers can observe different subject hierarchies, each maintaining its own position independently. A read-model updater watches /orders. An integration adapter watches /payments. An analytics pipeline watches / with recursion enabled. Each has its own pace, its own lag, its own restart behavior. None of them require a broker. None of them require an outbox.
Delivery is at-least-once. If a subscriber disconnects after processing event 2010 but before persisting its cursor, it will see 2010 again on reconnect. Idempotency on the consumer side is still required, but that requirement was always there. The outbox didn't remove it.
When the Outbox Is Still the Right Answer¶
We don't want to overclaim. There are situations where the outbox is exactly what you should reach for.
If your system is firmly committed to a relational database as the source of truth, with a separate broker for asynchronous communication, and you're not in a position to change that, the outbox is the correct workaround. It's a real solution to a real problem, and the alternatives, distributed transactions, dual-write-and-pray, change-data-capture pipelines with their own failure modes, are usually worse.
The point isn't that the outbox is bad. The point is that the outbox is a tax you pay for having two systems. If you can choose your architecture, the more interesting question isn't "should I use the outbox pattern?" It's "do I have two stores, or one?"
That question is harder than the outbox question, because it forces you to think about what you actually need. Many systems that reach for the outbox don't need a broker at all. They need durable, ordered, observable events. Once that's the framing, the broker becomes optional and the outbox along with it.
What You Build Instead¶
Without an outbox, the system gets simpler. The command handler appends events to the store. That's the entire write path. No outbox table, no relay process, no broker write, no second transaction.
Read models update by observing the store. That's the entire read path. No translation from outbox row to broker message to consumer record. The same NDJSON stream that the read-model updater consumes is the same stream that an integration adapter, a search-index builder, or an external notification service would consume. Different subscribers, same source.
Replay, the killer feature of event-driven systems, becomes trivial. Drop the read model, reconnect from event ID 0, rebuild. No reprocessing through a broker, no replay topic, no separate retention policy for the outbox versus the broker. The store has the events. The store has always had the events. You read them again.
This is the inversion of the outbox model. Instead of writing state to a database and then publishing events as a derivative, you write events as the primary act and derive state from them. The broker isn't gone; it was never necessary. The outbox isn't gone; it was a workaround for a problem you didn't have to have.
One Store, One Write¶
The outbox pattern is a clever, well-engineered, widely-deployed solution to a problem that comes from architectural choices made earlier. It's the correct answer if those choices are fixed. It is not the only correct answer if they aren't.
When the event is the persisted state, there is no two-step write. There is no broker to keep in sync. There is no relay to monitor, no lag to alert on, no dead-letter queue to drain. Subscribers connect directly to the store, remember their position, and resume where they left off. That isn't a workaround. It's just how the system works.
If you're building something new and the outbox pattern keeps surfacing in your design discussions, treat it as a signal. Ask whether the two systems it's trying to reconcile both need to exist. Sometimes they do. Often, they don't.
If you'd like to see what an outbox-free subscription model actually looks like in practice, the Building Event Handlers guide walks through cursor management, partitioning, and reconnection in detail.
And if you'd like to talk through whether collapsing two stores into one would simplify your architecture, we'd love to hear from you at hello@thenativeweb.io.