When I started building Foreman’s job submission flow, I hit a question I’d seen glossed over in tutorials: how do you atomically write to a database and publish a message to Kafka? The answer seems obvious — do both. But “do both” isn’t a transaction. One can succeed and the other can fail, and now your system is lying to itself.
This post is about the dual-write problem, why Kafka transactions don’t fix it, and how the transactional outbox pattern does.
The broken version
Here’s the naive job submission handler:
// DON'T DO THIS
func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) {
job := parseJobFromRequest(r)
if err := h.jobsRepo.Create(ctx, job); err != nil {
http.Error(w, "db write failed", 500)
return
}
// What if the process crashes here?
if err := h.producer.Publish(ctx, job.KafkaTopic(), job); err != nil {
// Job is in Postgres but will never be picked up.
// Do we delete the job? Retry the publish? Log and hope?
http.Error(w, "kafka write failed", 500)
return
}
w.WriteHeader(202)
}
The comment says it all. If the process crashes between the Postgres write and the Kafka produce, the job is in the database in pending state forever. No worker will ever see it. The client got a 500, so they don’t know whether to retry. If they do retry, they create a duplicate.
This isn’t a theoretical edge case — process crashes happen. OOM kills, deployments, kernel panics. On a system processing thousands of jobs per hour, this will happen.
Why Kafka transactions don’t help
At this point you might reach for Kafka’s transaction API. Kafka does support transactions — a producer can open a transaction, produce to multiple topics/partitions, and commit or abort atomically. If you’ve used two-phase commit in databases, it’s the same idea applied to Kafka.
But Kafka transactions cover the Kafka side only. They guarantee that either all your Kafka messages commit or none do. They say nothing about what happens in Postgres.
To atomically commit a Postgres write and a Kafka produce would require two-phase commit across Postgres and Kafka — and neither supports acting as the transaction coordinator for the other. This is a distributed transaction, and distributed transactions are famously hard to get right.
The outbox pattern sidesteps this entirely by not crossing the transaction boundary.
The outbox pattern
The insight: both writes happen inside a single Postgres transaction.
BEGIN;
INSERT INTO jobs (...) VALUES (...);
INSERT INTO outbox (topic, partition_key, payload, ...) VALUES (...);
COMMIT;
The outbox table is a staging area. Instead of writing directly to Kafka, the handler writes to a Postgres table in the same transaction as the job. If the transaction commits, both rows exist. If it fails (for any reason), neither does. Postgres’s ACID guarantees handle the atomicity.
Then a background goroutine — the outbox relay — polls for unpublished rows and produces them to Kafka:
func (r *OutboxRelay) flush(ctx context.Context) error {
entries, err := r.source.ListUnpublished(ctx, 100)
if err != nil {
return err
}
var published []int64
for _, e := range entries {
if err := r.producer.PublishRaw(ctx, e.Topic, e.PartitionKey, e.CorrelationID, e.Payload); err != nil {
r.logger.Error("produce failed", "outbox_id", e.ID, "err", err)
continue // retry on next tick
}
published = append(published, e.ID)
}
if len(published) > 0 {
return r.source.MarkPublished(ctx, published)
}
return nil
}
The relay runs every 100ms. What happens on crash? If the relay crashes after producing but before marking published, those rows remain unpublished. On the next startup, the relay picks them up and produces them again. The worker receives the message twice.
This is at-least-once delivery, not exactly-once. But the job is not lost.
Consumer-side idempotency
The duplicate delivery problem is solved on the consumer side: the worker checks the idempotency key before executing.
When a worker picks up a JobMessage from Kafka, the first thing it does is attempt to transition the job from pending to running in Postgres. If the job is already running or in a terminal state, it means a duplicate message arrived. The worker drops it and commits the Kafka offset.
The idempotency unique index (UNIQUE (tenant_id, idempotency_key)) enforces this at the database level — no race condition is possible between two workers racing to claim the same job.
Together, the outbox pattern (at-least-once) and the idempotency check (dedup) combine to give you the application-level guarantee that each job executes exactly once. It’s not a single atomic operation — it’s a carefully designed protocol.
What’s stored in the outbox
The outbox payload column is BYTEA, not JSONB. The payload is serialized protobuf bytes, stored exactly as they’ll be sent to Kafka. When the relay runs, it reads the bytes and calls WriteMessages directly — no deserialization, no re-serialization.
Why protobuf instead of JSON? Schema safety — covered in the next post. But the storage choice follows from the format: since Kafka messages are binary protobuf, storing the serialized bytes avoids a round-trip.
The invariant
Here’s the invariant the outbox pattern provides, stated plainly:
If a job row exists in Postgres with state
pending, there is an unpublished row in the outbox that will eventually be produced to Kafka and result in the job being picked up.
This is guaranteed by the transaction: the two rows are written atomically. And the relay is guaranteed to eventually produce the row (it retries on failure). The only gap is time — up to 100ms between commit and produce.
Crash scenarios
Let me trace through the scenarios that used to be failures:
Scenario: API crashes between Postgres commit and relay tick
jobsrow exists,outboxrow exists withpublished = FALSE- On restart, the relay polls and finds the unpublished row
- Produces to Kafka, marks published
- Worker picks up job and executes
- No job lost, no duplicate
Scenario: Relay crashes after Kafka produce but before marking published
outboxrow still haspublished = FALSE- On restart, relay produces again
- Worker receives duplicate message
- Worker checks: job already
completed→ drops message, commits offset - No job lost, no double execution
Scenario: Postgres transaction fails (e.g. idempotency conflict)
- Neither
jobsnoroutboxrow is written - Client receives 409 with the existing job
- No Kafka message produced, because there’s no outbox row to relay
- Clean failure
Operational consideration: outbox cleanup
The outbox table grows as jobs are submitted. Published rows are no longer needed but aren’t deleted automatically. A periodic goroutine (added later) runs DELETE FROM outbox WHERE published = TRUE AND published_at < NOW() - INTERVAL '24 hours'. Without this, the table grows unboundedly and the idx_outbox_unpublished partial index becomes less efficient as the table size diverges from the number of unpublished rows.
The outbox pattern is one of those things that feels like extra work until the first time you see a job queue silently drop work. The implementation in Foreman is about 100 lines of Go and a two-column SQL query. The protocol it implements has been battle-tested for years across distributed systems. Worth understanding deeply before dismissing it as complexity overhead.
Next up: the worker pool — goroutines, channels, and graceful shutdown under SIGTERM.