Writing the Event in the Same Transaction

I remember sitting in a dimly lit office at 2:00 AM, watching a dashboard bleed red as a distributed transaction failed halfway through. The database had committed the payment, but the message broker had silently dropped the event, leaving our downstream services in a state of total incoherence. Most tutorials on how to implement an outbox pattern make it sound like a simple matter of adding a new table and a polling worker, as if you’re just following a recipe for sourdough. They never mention the soul-crushing reality of race conditions, duplicate messages, or the moment your polling mechanism starts competing with your primary write operations for precious I/O.

I’m not here to show you the “happy path” where everything works perfectly on your local machine. I’ve spent twenty years maintaining legacy systems that broke in ways no documentation predicted, so I want to talk about the actual friction. I’m going to walk you through the architecture, but more importantly, I’ll show you the edge cases that actually matter—the ones that will keep you awake at night if you don’t account for them from day one.

Table of Contents

Why Distributed Transactions in Microservices Always Fail

Why Distributed Transactions in Microservices Always Fail

The tutorial won’t tell you this, but the moment you try to coordinate a single transaction across two different services, you’ve already lost. In a perfect world, you’d use a two-phase commit to ensure that your order service and your payment service both commit or both fail. In the real world, network partitions happen, services time out, and your coordinator hangs. Trying to manage distributed transactions in microservices is like trying to bind a book where the pages keep disappearing halfway through the process; you end up with a fragmented mess that no one can reconcile.

When you rely on these distributed locks, you aren’t just adding complexity; you are building a system that is brittle by design. If one node becomes sluggish, the entire chain stalls, and your latency spikes into the stratosphere. Most developers think they can just wrap everything in a global transaction and call it a day, but they forget that the network is an unreliable narrator. Instead of fighting the inevitable, we have to stop pretending we can achieve instant synchronization and start designing for eventual consistency patterns that actually survive a system crash.

The Messy Reality of Eventual Consistency Patterns Much Sooner Than Expecte

The Messy Reality of Eventual Consistency Patterns Much Sooner Than Expecte

The tutorial won’t tell you this, but eventual consistency patterns are rarely “eventual” in the way they describe. They describe a seamless transition from state A to state B. In reality, you’re going to live in the gap between those states for much longer than you planned. You’ll build a perfectly valid transactional outbox implementation, only to realize that your business logic assumes a level of synchronicity that simply doesn’t exist anymore. When a user updates their profile and the downstream service hasn’t seen that change yet because the relay is lagging, they don’t see “eventual consistency”—they see a broken application.

This gap is where the real work begins. You aren’t just writing code; you’re managing a state of limbo. If your message relay service hits a snag or your database experiences a momentary spike in latency, that window of inconsistency stretches. Suddenly, you’re not just dealing with a delay; you’re dealing with users who are refreshing pages, triggering duplicate requests, and wondering why their data seems to be fighting against them.

Five ways your implementation will actually fail in production

  • Stop treating the outbox table like a temporary buffer; it is a permanent part of your schema. If you don’t implement a strict cleanup policy or a TTL from day one, that table will grow until your indexes become useless and your vacuum processes start eating your CPU alive.
  • Your message relay needs to be idempotent, not just because it’s “good practice,” but because the network is a liar. You will eventually send the same event twice, and if your downstream consumers aren’t prepared to handle duplicates, you’ll be cleaning up corrupted state in your production database by midnight.
  • Don’t use a simple `SELECT * FROM outbox WHERE status = ‘PENDING’` loop. In a high-concurrency environment, you’ll end up with multiple instances of your service fighting over the same rows, causing massive lock contention. Use `SKIP LOCKED` if your database supports it, or you’ll be debugging deadlocks for the next three weeks.
  • The tutorial likely skipped the monitoring part, but you need an alert for “stale messages.” If a message sits in that table for more than five minutes without being processed, something is broken. If you don’t have a metric for the age of the oldest unprocessed message, you are flying blind.
  • Watch your transaction scope. The whole point of the pattern is to wrap the business logic and the outbox insert in a single atomic unit. If you find yourself calling the message relay inside that same transaction, you’ve just defeated the purpose and created a bottleneck that will throttle your entire system.

The Lessons You Won't Find in the Documentation

The Outbox pattern isn’t a “set and forget” solution; if you don’t implement a robust cleanup strategy for your outbox table, the very mechanism meant to ensure reliability will eventually become the bottleneck that brings your entire database to its knees.

Don’t mistake “eventual consistency” for “no consistency at all”—you need to build your downstream services with the assumption that messages will arrive late, out of order, or multiple times, otherwise, you’re just trading one type of failure for another.

Writing the code is the easy part; the real work is designing the failure modes, because the tutorial will show you how the message gets sent, but it won’t show you what to do when the relay service crashes halfway through a batch.

## The Cost of the Happy Path

“A tutorial will show you how to write a record to an outbox table and call it a day, but it won’t show you the 3:00 AM alert when your polling mechanism starts lagging, your table hits ten million rows, and your database locks up because you forgot that ‘eventual consistency’ actually requires someone to manage the mess in between.”

Pernille Askeland

The Reality Check

The Reality Check of implementing outbox patterns.

Implementing the outbox pattern isn’t a “set it and forget it” task that you can just check off a Jira ticket. We’ve looked at why distributed transactions are a fantasy and how eventual consistency can turn into a nightmare if you aren’t watching your tables. You need to account for the bloated outbox table, the race conditions in your relay service, and the inevitable moment when a message is processed twice. If you don’t build in idempotency from day one, the pattern won’t save your data integrity; it will just provide a more organized way to propagate errors across your entire architecture.

At the end of the day, software engineering is less about following the clean, white-space-heavy diagrams in a textbook and more about managing the entropy that occurs when things actually run in production. The outbox pattern is a tool, not a silver bullet, and it requires constant, disciplined maintenance to keep it from becoming the very bottleneck you were trying to avoid. Don’t aim for the perfect implementation that the tutorials promise; aim for the resilient implementation that knows how to fail gracefully when the database inevitably pushes back.

Frequently Asked Questions

What happens to my database performance when the outbox table starts growing by millions of rows every hour?

The tutorial won’t tell you this, but your outbox table is going to bloat until it chokes your database. Once you hit millions of rows an hour, your indexes stop being helpful and start being a liability. Every insert becomes a heavy operation, and your vacuuming or cleanup processes will struggle to keep up. Eventually, the sheer volume of writes will drive up IOPS and lock contention, turning your reliable event stream into a massive performance bottleneck.

How do I prevent the message relay from picking up the same event twice and causing duplicate transactions?

The tutorial tells you the relay polls the table and sends the message. It doesn’t tell you that the moment your relay crashes mid-process, you’re going to see duplicates everywhere. To stop this, you have to embrace idempotency on the consumer side. Don’t try to build a perfect, single-delivery relay—it’s a fool’s errand. Instead, ensure your downstream services check a unique transaction ID before processing anything. If they’ve seen it, they drop it.

If the relay service crashes halfway through a batch, how do I ensure I don't lose data or end up in an inconsistent state?

If your relay service dies mid-batch, you don’t lose data, but you do risk duplicates. The key is treating your outbox as a “pull” mechanism rather than a “push.” Your relay should fetch a batch, mark those rows as ‘processing’—or better yet, use a transaction to move them to a temporary state—and only delete or archive them once the downstream service acknowledges receipt. It’s about idempotency; your consumers must be able to handle the same message twice.

About Pernille Askeland

I write the part the tutorial skipped: what it looks like when it breaks.

Scroll to Top