Here's a failure mode that shows up in almost every event-driven system I've worked on: you write to the database and publish an event to a message broker in the same request. It works fine in development. In production, the write succeeds and the publish fails — network blip, broker restart, process crash — and now your database says one thing and your downstream consumers never knew.
You can't fix this with a try/catch. The write already committed. You can't roll it back after the fact.
The outbox pattern solves this cleanly, without distributed transactions.
The core idea
Instead of publishing to the broker directly, you write the event to an outbox table in the same database transaction as your business data. A separate process — the relay — polls the outbox table and publishes events to the broker, then marks them as sent.
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
topic TEXT NOT NULL,
payload JSONB NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
The application writes to both tables atomically:
BEGIN;
INSERT INTO orders (id, user_id, total) VALUES ($1, $2, $3);
INSERT INTO outbox (topic, payload) VALUES ('orders.created', $4);
COMMIT;
If the transaction fails, neither row is written. If it succeeds, both rows exist. The event is now guaranteed to exist as long as the order does.
The relay
The relay is a simple polling loop:
func (r *Relay) Run(ctx context.Context) {
ticker := time.NewTicker(500 * time.Millisecond)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.publishPending(ctx)
}
}
}
func (r *Relay) publishPending(ctx context.Context) {
rows, _ := r.db.QueryContext(ctx,
`SELECT id, topic, payload FROM outbox
WHERE published = FALSE
ORDER BY id ASC LIMIT 100`)
for rows.Next() {
// publish to broker, mark as published on success
}
}
This gives you at-least-once delivery. If the relay crashes after publishing but before marking the row, the event is published twice on the next run. Your consumers need to be idempotent — which they should be anyway in any event-driven system.
Fenced leasing for multiple relay instances
If you run multiple relay instances (for availability), two instances can race to publish the same event. We handle this with a fenced lease: the relay claims a batch of rows by updating them with a leased_until timestamp and a leased_by identifier. Only the instance holding the lease publishes.
UPDATE outbox
SET leased_until = NOW() + INTERVAL '30 seconds',
leased_by = $1
WHERE published = FALSE
AND (leased_until IS NULL OR leased_until < NOW())
LIMIT 100
RETURNING id, topic, payload;
Expired leases are automatically reclaimed on the next tick. No coordination service needed.
When not to use it
The outbox pattern adds a polling loop and an extra table. For low-volume internal services where you control both the publisher and consumer and can tolerate the occasional missed event, direct publishing is simpler and probably fine.
Where the outbox genuinely earns its keep: customer-facing webhooks, billing events, audit logs, and any place where "I'm not sure if that was sent" is unacceptable.
If you're building a webhook delivery system, the outbox pattern isn't optional — it's the foundation.