Siddhant Deval
Siddhant Deval
backend18 min read

Distributed Transactions: Two-Phase Commit, Transactional Outbox & CDC Event Publishing

Publishing an event to Kafka in the same handler that commits a database transaction is a dual-write hazard that silently loses events on partial failure. Learn how the Transactional Outbox pattern and CDC via Debezium WAL tailing provide exactly-once event publishing guarantees — and when Two-Phase Commit is still the right tool.

Series·Part 9 of 9

Distributed Architecture & System Design

Distributed Transactions: Two-Phase Commit, Transactional Outbox & CDC Event Publishing

Every boundary is a failure isolation decision — and the dual-write hazard is one of the most deceptively simple failure modes in distributed systems. The pattern appears in almost every codebase that integrates a relational database with Kafka or any external event stream:

Architectural Note

Series positioning: This is Part 9 — the capstone of the Distributed Architecture & System Design series. Having explored workflow coordination in Part 8: The SAGA Pattern, this final article resolves the dual-write hazard using the Transactional Outbox, Debezium CDC via WAL tailing, and the Consumer Inbox pattern to guarantee end-to-end exactly-once business outcomes.

TYPESCRIPT
// ❌ The dual-write hazard — appears harmless, silently loses events in production
async function placeOrder(cmd: PlaceOrderCommand): Promise<void> {
  await db('orders').insert({ id: cmd.orderId, userId: cmd.userId, status: 'pending' })
  // ✅ Database committed successfully

  await kafkaProducer.send('order.placed', { orderId: cmd.orderId, userId: cmd.userId })
  // ❌ Process crashes here — database committed, Kafka message never published
  // Downstream consumers never know the order exists
}

There is no atomic operation that spans a PostgreSQL commit and a Kafka publish. The application can crash between them. The network can fail. The Kafka broker can be temporarily unavailable. Every engineer who has written this pattern has introduced a silent data loss risk that only manifests under failure conditions — exactly when it matters most.


1. Two-Phase Commit: When It Works and When It Doesn't

Two-Phase Commit (2PC) is the classical distributed transaction protocol. It uses a coordinator to synchronize commits across multiple resource managers.

1.1 The 2PC Protocol

Performance / Safety Warning

When the 2PC coordinator crashes after PREPARE but before COMMIT, both databases hold their locks in an in-doubt transaction state indefinitely. Rows in the PREPARE phase are locked and cannot be read or written by any other transaction until the coordinator recovers and issues COMMIT or ROLLBACK. This is a blocking failure mode — 2PC provides durability at the cost of availability.

1.3 When to Use 2PC

✅ Use 2PC When ❌ Avoid 2PC When
Both databases are PostgreSQL (XA-compatible) within a single trust boundary Coordinating with a message broker (Kafka has no XA support)
Coordinator failure is recoverable within seconds (high-availability coordinator) Coordinating across microservice API boundaries
The transaction volume is low (2PC blocks during coordinator outage) Third-party APIs are involved (cannot roll back an external charge)
Strong consistency is a hard requirement High throughput is required (in-doubt lock blocking degrades concurrency)

2. The Transactional Outbox Pattern

The Transactional Outbox eliminates the dual-write hazard by treating the event payload as a row in the same database transaction as the domain state change.

Dual-write hazard versus transactional outbox comparison showing application crash between database commit and Kafka publish causing silent event loss (left) against atomic database outbox commit with asynchronous CDC relay (right).
Dual-write hazard versus transactional outbox comparison showing application crash between database commit and Kafka publish causing silent event loss (left)…

2.1 Outbox Table Schema

SQL
-- Outbox table — lives in the same database as the domain tables
CREATE TABLE outbox_events (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  aggregate_type  TEXT NOT NULL,    -- e.g. 'Order'
  aggregate_id    TEXT NOT NULL,    -- e.g. 'order-abc123'
  event_type      TEXT NOT NULL,    -- e.g. 'OrderPlaced'
  payload         JSONB NOT NULL,   -- full event body
  published_at    TIMESTAMPTZ,      -- null = unpublished
  created_at      TIMESTAMPTZ DEFAULT now(),
  idempotency_key TEXT UNIQUE NOT NULL -- prevents duplicate publishing on relay retry
);

CREATE INDEX idx_outbox_unpublished ON outbox_events (created_at)
  WHERE published_at IS NULL;   -- partial index on unpublished rows only

2.2 Atomic Write: Domain State + Outbox Row

TYPESCRIPT
// ✅ Transactional Outbox — domain write and event payload in one atomic transaction
async function placeOrder(cmd: PlaceOrderCommand): Promise<void> {
  await db.transaction(async (trx) => {
    // Write 1: persist domain state
    await trx('orders').insert({
      id: cmd.orderId, user_id: cmd.userId,
      total_cents: cmd.totalCents, status: 'pending'
    })

    // Write 2: persist event payload in the outbox — SAME TRANSACTION
    await trx('outbox_events').insert({
      aggregate_type:  'Order',
      aggregate_id:    cmd.orderId,
      event_type:      'OrderPlaced',
      payload:         JSON.stringify({
        orderId: cmd.orderId, userId: cmd.userId,
        totalCents: cmd.totalCents, placedAt: new Date().toISOString()
      }),
      idempotency_key: `OrderPlaced-${cmd.orderId}`,
    })
    // Either BOTH commit → event will be published to Kafka by relay
    // Or BOTH rollback → no orphaned event, no missing event
  })
}

2.3 Polling Relay (Simple)

TYPESCRIPT
// ✅ Polling relay — reads unpublished outbox rows and publishes them to Kafka
// Simple to implement; adds polling latency (typically 100ms–1s)
class OutboxPollingRelay {
  async run(): Promise<void> {
    while (true) {
      const unpublished = await db('outbox_events')
        .whereNull('published_at')
        .orderBy('created_at', 'asc')
        .limit(100)
        .forUpdate()  // pessimistic lock — prevents double-publishing from parallel relays
        .skipLocked() // skip rows locked by another relay instance

      for (const row of unpublished) {
        await kafkaProducer.send({
          topic: `${row.aggregate_type.toLowerCase()}.${row.event_type.toLowerCase()}`,
          messages: [{ key: row.aggregate_id, value: row.payload }]
        })
        await db('outbox_events').where({ id: row.id }).update({ published_at: new Date() })
      }

      await sleep(100) // 100ms polling interval
    }
  }
}

3. Debezium CDC — WAL-Based Event Publishing

The polling relay adds 100ms–1s publishing latency and requires a polling process to run continuously. Change Data Capture (CDC) via Debezium tails the database Write-Ahead Log (WAL) directly, publishing outbox row inserts to Kafka in near real-time without application polling.

3.1 How WAL Tailing Works

3.2 Debezium Configuration

JSON
{
  "name": "order-outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "postgres",
    "database.port": "5432",
    "database.user": "debezium",
    "database.password": "${DB_PASSWORD}",
    "database.dbname": "orders_db",
    "table.include.list": "public.outbox_events",
    "slot.name": "debezium_outbox_slot",
    "plugin.name": "pgoutput",
    "publication.name": "debezium_outbox_publication",

    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.table.field.event.id": "idempotency_key",
    "transforms.outbox.table.field.event.key": "aggregate_id",
    "transforms.outbox.table.field.event.payload": "payload",
    "transforms.outbox.route.by.field": "event_type",
    "transforms.outbox.route.topic.replacement": "order.${routedByValue}"
  }
}

3.3 Polling Relay vs CDC Comparison

Property Polling Relay Debezium CDC
Publishing latency 100ms–1s (polling interval) < 100ms (WAL tail)
Dependency Application process only Debezium connector + Kafka Connect cluster
Operational complexity Low Medium (WAL slot management, lag monitoring)
Database load Polling query load on outbox_events WAL replication slot (low overhead)
Delivery guarantee At-least-once At-least-once
Recommended for Low-throughput (< 100 events/s) High-throughput (> 100 events/s)

4. The Inbox: Idempotent Consumer

The Outbox guarantees at-least-once publishing — Kafka may deliver the message more than once (relay retry, rebalance, etc.). The Inbox pattern on the consumer side provides idempotent deduplication to achieve end-to-end exactly-once semantics:

SQL
-- Consumer side: inbox deduplication table
CREATE TABLE inbox_events (
  idempotency_key TEXT PRIMARY KEY,   -- same key as outbox.idempotency_key
  processed_at    TIMESTAMPTZ DEFAULT now()
);
TYPESCRIPT
// ✅ Idempotent consumer — deduplicates using the inbox table
class FulfillmentConsumer {
  async handleOrderPlaced(message: KafkaMessage): Promise<void> {
    const event = JSON.parse(message.value!.toString()) as OrderPlacedEvent
    const idempotencyKey = message.headers?.['idempotency-key']?.toString()

    await db.transaction(async (trx) => {
      // Check if this event was already processed
      const existing = await trx('inbox_events')
        .where({ idempotency_key: idempotencyKey })
        .first()

      if (existing) {
        // Already processed — skip idempotently (no duplicate fulfillment)
        return
      }

      // Process the event
      await trx('fulfillments').insert({
        order_id: event.orderId, status: 'pending', created_at: new Date()
      })

      // Mark as processed in the inbox — in the same transaction
      await trx('inbox_events').insert({ idempotency_key: idempotencyKey })
      // Either both commit (processed + inbox record) or both rollback (retry on next delivery)
    })
  }
}

5. End-to-End Exactly-Once Guarantee

End-to-end exactly-once architecture showing transactional outbox on PostgreSQL producer, at-least-once Kafka broker transport, and atomic consumer inbox deduplication table achieving exactly-once business outcome.
End-to-end exactly-once architecture showing transactional outbox on PostgreSQL producer, at-least-once Kafka broker transport, and atomic consumer inbox ded…

The system achieves end-to-end exactly-once without requiring Kafka's transactional API:

  • Producer: domain state + outbox row commit atomically (or both roll back)
  • Transport: Kafka delivers at-least-once (duplicates possible)
  • Consumer: inbox deduplication ensures each business effect runs exactly once

6. WAL Slot Management & Operational Concerns

BASH
# Monitor Debezium replication slot lag — critical for storage management
SELECT
  slot_name,
  restart_lsn,
  confirmed_flush_lsn,
  pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS lag_bytes
FROM pg_replication_slots
WHERE active = true;

# A lagging slot causes WAL to accumulate on disk — unbounded growth if CDC is paused
# Alert when lag_bytes > 1GB; set max_slot_wal_keep_size to prevent disk exhaustion
SQL
-- postgresql.conf / ALTER SYSTEM
wal_level = logical                      -- required for Debezium
max_replication_slots = 5               -- one per connector
max_wal_senders = 5                     -- one per slot
max_slot_wal_keep_size = '2GB'          -- prevent runaway WAL growth if CDC lags

Summary

Architectural Concern Production Rule
Dual-Write Hazard Publishing to Kafka after (or before) a DB commit has an unavoidable race window; never rely on application-level coordination.
Transactional Outbox Domain state + event payload commit in one ACID transaction; the event is guaranteed to persist alongside state.
Debezium CDC WAL tailing publishes outbox rows to Kafka in sub-second latency without application polling; provides at-least-once transport.
Inbox Pattern Consumers deduplicate using per-message idempotency keys in an ACID inbox table within the processing transaction.
End-to-End Exactly-Once Outbox (producer) + at-least-once Kafka + Inbox (consumer) = exactly-once business outcome without Kafka transaction overhead.

Series Conclusion

This concludes the Distributed Architecture & System Design series. You have now mastered the full progression of distributed systems engineering — from strategic bounded context decomposition and RPC communication protocols, to high-throughput messaging brokers (Kafka & RabbitMQ), CQRS command/query models, append-only Event Sourcing, zero-downtime Materialized View rebuilds, distributed SAGA workflows, and the Transactional Outbox with Debezium CDC.

The unifying principle across all nine parts remains: every boundary is a failure isolation decision; design every message, transaction, and state machine as if partial failure is the default, not the exception.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Distributed Transactions#Kafka#CDC#Outbox Pattern#Backend
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.