Outbox Pattern Polling vs WAL

PostgreSQLBackendArchitecture
Share on LinkedIn Share on X Share on Reddit Share on HN Share on Bluesky

The transactional outbox pattern guarantees that domain state changes and event publication intent commit atomically. The remaining design question — how unpublished events reach your message broker — splits teams into two camps: poll the outbox table or tail the WAL via logical replication. Both work. They differ in latency, operational complexity, failure modes, and how they behave under load.

This article compares both approaches with enough implementation detail to choose confidently for your throughput and latency requirements.

The outbox contract

Regardless of relay mechanism, the outbox contract is identical:

BEGIN;
  UPDATE orders SET status = 'shipped' WHERE id = $1;
  INSERT INTO outbox (aggregate_id, event_type, payload, created_at)
  VALUES ($1, 'OrderShipped', '{"orderId": ...}', now());
COMMIT;

Either both statements persist or neither does. The relay mechanism — polling or WAL — is a separate process that reads committed outbox rows and publishes to Kafka, SNS, or RabbitMQ.

Polling relay architecture

A background worker loop:

SELECT id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;

For each row: publish to broker, then mark published:

UPDATE outbox SET published_at = now() WHERE id = $1;

Or delete after publish for append-only outbox tables with archival elsewhere.

Worker pseudocode:

while True:
    rows = db.fetch_unpublished(limit=100)
    if not rows:
        time.sleep(POLL_INTERVAL)  # 100ms - 1s typical
        continue
    for row in rows:
        broker.publish(row.event_type, row.payload)
        db.mark_published(row.id)

Polling strengths

Polling weaknesses

Polling optimizations

Adaptive poll interval: Sleep 100ms when rows found, backoff to 1s when empty.

Partial index for unpublished rows:

CREATE INDEX outbox_unpublished_idx ON outbox (id)
WHERE published_at IS NULL;

Batch publish: Publish entire batch to broker in one produce call, then batch UPDATE.

Separate mark step in same transaction as publish using the transactional producer pattern (see companion article on Kafka) — or accept at-least-once and rely on consumer idempotency.

WAL-based CDC relay architecture

Postgres logical replication streams row-level changes from the WAL to a consumer. Debezium (or pgoutput-native consumers) watches the outbox table:

Business TX → INSERT outbox row → WAL record
                                      ↓
                              Logical replication slot
                                      ↓
                              Debezium connector
                                      ↓
                              Kafka topic

Debezium configuration snippet:

{
  "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
  "database.server.name": "orders-db",
  "table.include.list": "public.outbox",
  "plugin.name": "pgoutput",
  "publication.name": "outbox_pub",
  "transforms": "outbox",
  "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter"
}

The Outbox Event Router transform maps table columns to Kafka message key, topic, and payload automatically.

Setup on Postgres:

-- Requires wal_level = logical
CREATE PUBLICATION outbox_pub FOR TABLE outbox;

-- Replication slot created by Debezium on first connect
-- SELECT slot_name, confirmed_flush_lsn FROM pg_replication_slots;

WAL CDC strengths

WAL CDC weaknesses

WAL slot monitoring — non-negotiable

SELECT slot_name,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) AS retained_wal
FROM pg_replication_slots
WHERE slot_type = 'logical';

Alert when retained WAL exceeds 1GB. A stalled Debezium connector with an active slot can fill your disk and crash Postgres.

Side-by-side comparison

Dimension Polling WAL CDC
Publish latency Poll interval (100ms–1s+) ~10–100ms
DB read load Repeated SELECT queries WAL stream (minimal query load)
Setup complexity Low High
Failure: worker down Backlog grows, no WAL retention Slot retains WAL — disk risk
Exactly-once to broker Hard (needs transactional producer) Hard (same — at-least-once typical)
Multi-table outbox Single worker can batch across tables One connector per table or route transforms
Local dev experience Trivial (run worker process) Needs Connect + Kafka stack

Hybrid approaches

Teams often start with polling and migrate to WAL CDC when latency requirements tighten:

  1. Phase 1: Polling worker, idempotent consumers, partial index
  2. Phase 2: Add Debezium in shadow mode — compare events without consuming downstream
  3. Phase 3: Cut over consumers to CDC-sourced topics, retire polling worker
  4. Phase 4: DELETE published rows via scheduled job (both approaches need this)

Some architectures run both: polling as fallback when the replication slot lag exceeds a threshold. The polling worker picks up anything the CDC stream missed during connector downtime. Duplicate events are handled by consumer idempotency keys.

Choosing for your workload

Choose polling when:

Choose WAL CDC when:

Choose hybrid when:

Outbox table design for both approaches

Design the table to serve either relay:

CREATE TABLE outbox (
  id            bigserial PRIMARY KEY,
  aggregate_type text NOT NULL,
  aggregate_id   text NOT NULL,
  event_type     text NOT NULL,
  payload        jsonb NOT NULL,
  created_at     timestamptz NOT NULL DEFAULT now(),
  published_at   timestamptz
);

CREATE INDEX outbox_unpublished_idx ON outbox (id)
WHERE published_at IS NULL;

For Debezium Outbox Event Router, add routing columns:

ALTER TABLE outbox ADD COLUMN topic text NOT NULL DEFAULT 'domain-events';
ALTER TABLE outbox ADD COLUMN partition_key text;

Polling workers use published_at IS NULL. Debezium reads all INSERTs regardless — use a separate published_at update from a downstream consumer acknowledgment, or DELETE rows after Kafka ack via a compaction consumer.

Idempotency regardless of relay choice

Both polling and WAL CDC deliver at-least-once to the broker. Design consumers with idempotency keys:

CREATE TABLE processed_events (
  idempotency_key text PRIMARY KEY,
  processed_at    timestamptz NOT NULL DEFAULT now()
);

Key on outbox.id or a business idempotency key in the payload. The relay mechanism does not change consumer requirements.

Cleanup and table bloat

Published outbox rows accumulate in both models. Without cleanup:

Cleanup job:

DELETE FROM outbox
WHERE published_at IS NOT NULL
  AND published_at < now() - interval '7 days';

Run during low-traffic windows with LIMIT batches to avoid long locks.

Summary

Polling the outbox table is the right default for most applications — simple, debuggable, and sufficient until latency or throughput demands push you elsewhere. WAL-based CDC via logical replication eliminates poll latency and database read overhead at the cost of replication slot monitoring, infrastructure complexity, and disk retention risk during consumer outages. The outbox table and atomic write pattern stay the same either way; only the relay changes. Start simple, measure publish lag against your SLO, and migrate to WAL CDC when the numbers justify the operational burden.

Frequently asked questions

When is polling the outbox table good enough?

Polling works well below roughly 500 events per second, when sub-second publish latency is acceptable, and when you want the simplest operational model — a background worker SELECT-ing unpublished rows. Add FOR UPDATE SKIP LOCKED, batch sizes tuned to your commit rate, and exponential backoff on empty polls.

What latency advantage does WAL-based CDC provide over polling?

WAL tailing delivers events within milliseconds of commit because the replication slot streams changes as they hit the write-ahead log. Polling introduces at least one poll interval of delay — often 100ms to 1s — plus query overhead. For user-facing read-your-writes across services, WAL CDC closes that gap.

Does WAL-based outbox publishing still require an outbox table?

Yes. The outbox table enforces atomic write-and-publish intent within the business transaction. WAL CDC replaces the polling relay — it watches the outbox table itself via logical replication rather than the application polling it. You still INSERT into outbox inside the same transaction as your domain write.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →