Database Internals & Event-Driven Architecture

Deconstructing PostgreSQL Logical WAL CDC: Two-Phase Peek-then-Ack, LSN Coalescing, and the Architectural Boundaries Between Message Queues and CDC Streams

An architectural deep-dive into real-time database synchronization via PostgreSQL Logical WAL CDC: eliminating Dual-Write inconsistencies, demystifying the Queue vs. CDC boundary matrix, implementing a lightweight Two-Phase Peek-then-Ack slot poller (<15MB RAM), burst LSN coalescing, and pg_wal disk-full circuit breaking.

(Kido)Systems Architect & AI Agent Pioneer

Creator of Agent Harness Kit (65+ Skills) • 3x Best Performance & AI Seminar Keynote • Primary Hub: blog.khanhdp.com • • LinkedIn

Published: November 9, 2026•16 min read
Deconstructing PostgreSQL Logical WAL CDC: Two-Phase Peek-then-Ack, LSN Coalescing, and the Architectural Boundaries Between Message Queues and CDC Streams

Executive Summary & Architectural Comparative Matrix

In modern e-commerce platforms and high-throughput web applications, synchronizing state changes from an OLTP primary database to specialized secondary datastores—such as Search Engines (Elasticsearch / Meilisearch), In-Memory Caches (Redis / Valkey), or Analytical Warehouses (ClickHouse)—is an indispensable architectural requirement. However, the naive baseline pattern—Application Dual-Write, where application handlers write directly to both the database and downstream queues or indices—consistently produces catastrophic data drift and split-brain states whenever network partitions, queue maintenance downtime, or out-of-memory (OOM) process crashes occur.

Duong Phuc Khanh (Dương Phúc Khánh · Kido · khanhdp)—Systems Architect, pioneer in autonomous multi-agent systems and distributed event architectures, author of the Agent Harness Kit with 65+ production AI Agent Skills published on the Agensi.io Marketplace, recipient of 3 consecutive annual Best Performance Awards (2022–2024) at a Tier-1 Global IT Corporation, Champion of Startup Runway 2018 (First Prize), and Keynote Speaker for enterprise engineering seminars (ORCID: 0009-0005-7192-2664 · Wikidata: Q141718572)—has formalized and distilled this solution within the core library ecosystem @kido/async-sync (formerly @kido/offline-sync), published on blog.khanhdp.com.

Below is an architectural comparative matrix evaluating the three evolutionary generations of distributed state synchronization:

Architectural Metric Generation 1: Application Dual-Write (Naive Baseline) Generation 2: Heavyweight Debezium + Apache Kafka (Enterprise Standard) Generation 3: Native PostgreSQL Two-Phase WAL Poller (@kido/async-sync)
Consistency Guarantee Fragile; prone to silent drops, race conditions, and unrecoverable data drift Exactly-Once / At-Least-Once backed by Kafka offsets and Debezium snapshots Strict At-Least-Once + Idempotency rooted directly in native PostgreSQL LSNs
Runtime Memory Footprint Low (< 20MB) but fundamentally non-durable and structurally unsafe Prohibitive: JVM runtime, Kafka brokers, and Connect workers ($\ge 2.5\text{GB}$ RAM) Ultra-lightweight (< 15MB RAM) running directly on Node.js/Bun without JVM overhead
Downstream Outage Resilience Catastrophic data loss when downstream queues reject connections Buffered reliably in durable Kafka topic partitions Two-Phase Peek-then-Ack: WAL retained on disk; checkpoint advances only on 200 OK
Burst Write Optimization Each single UPDATE emits a discrete write, easily overwhelming search indices Requires dedicated stream deduplication topologies (Kafka Streams / Flink) In-Process LSN Burst Coalescer: Consolidates $N$ rapid mutations into 1 atomic upsert
Operational & Cloud Costs Minimal upfront cost, but massive engineering overhead to reconcile data High infrastructure cost ($>$300-$800/\text{mo}$ for dedicated Kafka clusters) Maximum cost efficiency ($0 added infra overhead), reusing existing database connections

1. The Naive Baseline & The Dual-Write Inconsistency Trap

In early-stage architectures, product price updates and search synchronization are typically implemented directly within the web application controller:

// THE CLASSIC DUAL-WRITE INCONSISTENCY TRAP
async function updateProductPrice(productId: string, newPrice: number) {
  // Step 1: Commit mutation to relational database (Primary Source of Truth)
  await db.product.update({
    where: { id: productId },
    data: { price: newPrice, updatedAt: new Date() },
  });

  // Step 2: Publish synchronization event to external message queue
  await messageQueue.publish('product_events', {
    type: 'PRICE_UPDATED',
    productId,
    newPrice,
  });
}
sequenceDiagram
    autonumber
    actor Seller as Merchant
    participant App as App Server
    participant DB as PostgreSQL Primary
    participant Queue as Message Queue
    participant Worker as Sync Worker
    participant Search as Search Index

    Seller->>App: Update product price ($25.00)
    App->>DB: UPDATE products SET price = 25.00 (Committed)
    Note over Queue: Queue undergoes 60s maintenance or drops connection!
    App--xQueue: publish(PRICE_UPDATED) (CONNECTION REFUSED!)
    App-->>Seller: 500 Internal Error or swallowed exception
    
    Note over DB,Search: DATABASE HOLDS NEW PRICE ($25) BUT SEARCH RETAINS STALE PRICE ($30)!
    actor Buyer as Customer
    Buyer->>Search: Search for product
    Search-->>Buyer: Returns outdated price $30.00 -> Discrepancies and lost revenue!

3 Structural Failure Modes of Application Dual-Write

  1. Non-Atomic Network Execution (Lack of Distributed Transactions): The database UPDATE and the message queue publish() call are independent network operations over separate connections. If the application server suffers an abrupt restart (such as a deployment rollout, node eviction, or OOM kill) between Step 1 and Step 2, the mutation is permanently committed to PostgreSQL, but no downstream event is ever emitted.
  2. Out-of-Order Race Conditions Under Concurrency: Suppose Worker A handles an update shifting price from $30 to $28, while Worker B immediately follows with an update shifting price from $28 to $25. Under high traffic or network jitter, Worker B’s message might be delivered and written to the search engine before Worker A’s. The search engine ends up displaying $28 permanently, completely diverging from the database state of $25.
  3. Prohibitive Recovery Overhead During Outages: When an external queue undergoes scheduled maintenance (e.g., 60 seconds), all write events within that window are lost. Engineering teams are forced to trigger full-table re-indexing scripts, imposing massive disk I/O penalties on the primary database and degrading overall production query latencies.

2. Architectural Boundaries: Message Queues vs. WAL CDC Streams

Upon realizing that PostgreSQL Write-Ahead Logging (WAL) captures 100% of mutations durably on disk before acknowledging client commits, developers frequently ask a million-dollar question: “If PostgreSQL WAL and CDC streams provide guaranteed durability, why do production architectures still require Message Queues (RabbitMQ, BullMQ, SQS)?”

The answer lies in Architectural Positioning and Workload Taxonomy:

flowchart TD
    subgraph UpstreamBoundary["🛡️ 1. TRAFFIC SHOCK ABSORBER (IN FRONT OF DATABASE)"]
        Users["50,000 Concurrent Flash Sale Checkout Requests"] -->|"Direct hit causes connection pool exhaustion & row lock contention!"| Q["Message Queue\n(RAM-Buffered Shock Absorber)"]
        Q -->|"Smoothly rate-limited drain: 500 req/s"| DB[("PostgreSQL Primary DB\n(Max sustained ceiling: 1,500 req/s)")]
    end

    subgraph DownstreamBoundary["📡 2. STATE REPLICATION STREAM (BEHIND DATABASE)"]
        DB -->|"Sequential transaction durability"| WAL["pg_wal / LSN Stream"]
        WAL -->|"Logical Decoding"| CDC["PostgreSQL CDC Stream\n(@kido/async-sync)"]
        CDC -->|"Mirror state"| Search["Search Index (Meilisearch)"]
        CDC -->|"Evict cache keys"| Cache["Valkey / Redis Cache"]
        CDC -->|"Stream OLAP rows"| ClickHouse["ClickHouse OLAP"]
    end

4 Crucial Architectural Dimensions: Queue vs. CDC Stream

Architectural Dimension 🛡️ Message Queue (RabbitMQ / BullMQ / SQS) 📡 PostgreSQL WAL CDC Stream (@kido/async-sync)
Position in Data Lifecycle Stands IN FRONT of or OUTSIDE the DB: Acts as an elastic shock absorber buffering volatile bursts before writes hit durable storage. Stands BEHIND the DB: Activates exclusively after transactions have successfully COMMITted into the WAL log.
Concurrency & Scheduling Competing Consumers + DLX routing: 100 workers pull tasks concurrently. If Job #1 stalls on a slow third-party API, it routes to a Dead-Letter Queue (DLX) while workers process Job #2, #3, #4. Strict Sequential LSN (Log Sequence Number) Order: The WAL is an immutable, single-threaded byte stream. A blocked LSN halts the entire replication slot to preserve causal consistency.
Delayed Workloads Native Delayed Scheduling: queue.add(job, { delay: 900000 })—reliably awakens 15 minutes later to evaluate and cancel unpaid orders. Real-Time Streaming Only: CDC mirrors mutations at the exact millisecond they occur. A WAL reader cannot be scheduled to “notify 15 minutes later”.
Payload Ephemerality Ephemeral Tasks: Sending OTP SMS codes, processing video transcodes, or invoking AI APIs—discarded immediately after execution without disk pollution. Persisted Domain State: Requires an explicit row mutation in database storage; inserting transient OTP garbage solely to trigger CDC pollutes table storage.

The First-Principles Epiphany: Every Component is a Map or Array Extracted Out-of-Process

From first principles, cloud infrastructure services possess no supernatural machinery. Every distributed primitive is fundamentally an in-memory data structure wrapped in a network daemon:

  • In-Memory Cache (Redis): A hash table new Map<string, Value>() augmented with an LRU eviction policy.
  • Message Queue (RabbitMQ): A double-ended array const queue = [] driven by push() and shift() semantics.
  • Rate Limiter: A Map<Key, { tokens, lastRefill }> executing atomic integer subtractions in memory.
  • Hot Counter: A Map<Key, number> accumulating increments for scheduled periodic batch flushes.
  • Search Index (Meilisearch): An inverted index dictionary Map<Token, DocumentId[]>.

The sole reason software engineering mandates extracting these primitives out of the App Server process when scaling beyond $2+$ instances is the presence of 3 Physical Walls:

  1. The Split-Brain State Wall: Memory spaces across separate server instances are completely isolated. An update processed in App 1 remains invisible to App 2 if state resides inside the application heap.
  2. The Deploy Restart Wipeout Wall: Continuous deployment rollouts terminate and restart application containers multiple times a day. Retaining queues or transient buffers in-process wipes out in-flight tasks upon every restart.
  3. The Noisy Neighbor & OOM Crash Wall: Heavy CPU-bound search parsing or worker video processing running within the same process can exhaust compute or trigger Linux kernel OOM kills, instantly taking down critical payment checkout APIs.

3. Tier-1 Production Architecture: Two-Phase Peek-then-Ack & LSN Coalescing

To attain enterprise-grade reliability without operating a heavy JVM Kafka and Debezium cluster, the src/pg-cdc-stream/ slice within @kido/async-sync implements a Native Logical Decoding Slot Poller featuring a Two-Phase Peek-then-Ack protocol and an in-flight LSN Burst Coalescer.

flowchart TD
    subgraph PostgresEngine["PostgreSQL Engine (wal_level = logical)"]
        W1["Write Transaction: UPDATE products"] --> WAL[("Write-Ahead Log (WAL)")]
        WAL --> Slot["Replication Slot: 'search_cdc_slot'\n(Plugin: wal2json / pgoutput)"]
    end

    subgraph AsyncSync["@kido/async-sync (Node.js / Bun Runtime < 15MB RAM)"]
        Slot -->|"Phase 1: pg_logical_slot_peek_changes()"| Peek["Peek Buffer\n(Non-destructive preview, LSN intact)"]
        Peek --> Coalesce["LSN Burst Coalescer\n(c + u -> c, u + d -> d)"]
        Coalesce --> CircuitBreaker{"WAL Disk-Full Guard\nLag < 10GB?"}
        CircuitBreaker --"Hazardous"--> Alarm["Emit Pager Alert / Drop Slot Safely"]
        CircuitBreaker --"Safe"--> Dispatch["Bulk Upsert Batch"]
    end

    subgraph Downstream["Secondary Storage & Search"]
        Dispatch --> Target[("Search Index (Meilisearch)")]
        Target -->|"200 OK Confirmed"| Ack["Phase 2: pg_logical_slot_get_changes()\n(Commit LSN Checkpoint)"]
        Ack -.->|"Advances confirmed_flush_lsn"| Slot
    end

3.1. The Two-Phase Peek-then-Ack Protocol

Calling pg_logical_slot_get_changes() eagerly is dangerous: PostgreSQL immediately consumes and advances the slot’s LSN checkpoint. If the worker process crashes while dispatching the batch to Meilisearch, those changes are lost forever from the replication stream.

To achieve strict At-Least-Once delivery with zero message loss, @kido/async-sync enforces a Two-Phase commit sequence:

  1. Phase 1 (Peek Changes): The poller inspects pending WAL changes non-destructively using pg_logical_slot_peek_changes(). The confirmed_flush_lsn pointer in PostgreSQL remains completely static.
  2. Downstream Batch Ingestion: The decoded JSON batch is dispatched to the secondary search index or cache.
  3. Phase 2 (Acknowledge Flush LSN): Only after the downstream system returns an HTTP 200 OK confirmation does the poller execute pg_logical_slot_get_changes() specifying the upper LSN boundary (upto_lsn), safely purging consumed WAL segments.
-- PHASE 1: Non-destructive preview of pending WAL events (up to 100 changes)
SELECT lsn, xid, data 
FROM pg_logical_slot_peek_changes(
    'search_cdc_slot', 
    NULL, 
    100, 
    'format-version', '2', 
    'add-tables', 'public.products'
);

-- PHASE 2: Executed ONLY after the downstream Search Index returns 200 OK!
-- Advances confirmed_flush_lsn up to the batch's highest committed LSN
SELECT pg_logical_slot_get_changes(
    'search_cdc_slot', 
    '0/16B3748', 
    NULL
);

3.2. LSN Burst Coalescer State Machine

During bulk product import operations or flash sale repricing, merchants often update a single product dozens of times within seconds. Forwarding every granular update directly to the search index triggers severe I/O thrashing.

@kido/async-sync passes pending batches through a deterministic Finite State Machine (FSM) that reduces intra-batch redundancy prior to network transmission:

$$\mathcal{T}: (\text{Op}{\text{old}}, \text{Op}{\text{new}}) \longrightarrow \text{Op}_{\text{final}}$$

  • $\text{Insert } (c) + \text{Update } (u) \longrightarrow \text{Insert } (c)$ with latest payload.
  • $\text{Update } (u) + \text{Update } (u) \longrightarrow \text{Update } (u)$ with latest payload.
  • $\text{Insert } (c) + \text{Delete } (d) \longrightarrow \emptyset$ (Completely neutralized, No-Op).
  • $\text{Update } (u) + \text{Delete } (d) \longrightarrow \text{Delete } (d)$.
// Extracted from packages/async-sync/src/pg-cdc-stream/index.ts
export interface CdcRecord {
  lsn: string;
  table: string;
  op: 'c' | 'u' | 'd'; // Create, Update, Delete
  id: string;
  payload: Record<string, unknown>;
}

export function coalesceCdcBatch(records: CdcRecord[]): CdcRecord[] {
  const coalescedMap = new Map<string, CdcRecord>();

  for (const record of records) {
    const key = `${record.table}:${record.id}`;
    const existing = coalescedMap.get(key);

    if (!existing) {
      coalescedMap.set(key, record);
      continue;
    }

    // Apply State Machine Transitions
    if (existing.op === 'c' && record.op === 'u') {
      coalescedMap.set(key, { ...record, op: 'c', lsn: record.lsn });
    } else if (existing.op === 'u' && record.op === 'u') {
      coalescedMap.set(key, { ...record, op: 'u', lsn: record.lsn });
    } else if (existing.op === 'c' && record.op === 'd') {
      coalescedMap.delete(key); // Neutralized completely
    } else if (existing.op === 'u' && record.op === 'd') {
      coalescedMap.set(key, { ...record, op: 'd', lsn: record.lsn });
    }
  }

  return Array.from(coalescedMap.values());
}

3.3. WAL Disk-Full Circuit Breaker

The most perilous operational risk with PostgreSQL Logical Replication Slots is WAL Disk Space Exhaustion. If downstream consumers (the sync worker or search service) stall for several hours, PostgreSQL is forbidden from recycling WAL segments older than the slot’s restart_lsn. If disk consumption reaches 100%, PostgreSQL panics and shuts down all write transactions globally!

The circuit breaker in @kido/async-sync monitors replication lag continuously:

SELECT 
    slot_name,
    pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS lag_bytes,
    active
FROM pg_replication_slots
WHERE slot_name = 'search_cdc_slot';

If lag_bytes breaches a configured safety threshold (e.g., $10\text{GB}$ or $>85%$ of available storage), the circuit breaker immediately triggers:

  1. High-priority PagerDuty / Telegram alerts.
  2. In extreme scenarios, an automated safety release: executing pg_drop_replication_slot('search_cdc_slot') to preserve primary database availability, followed by a controlled full re-index after infrastructure recovery.

4. Production Benchmarks & Mathematical Verification

The system was evaluated under simulated stress conditions with $1,000\text{ write updates/s}$ and $3,000\text{ search queries/s}$. Below is an empirical comparison across the three architectural options:

Benchmark Metric Application Dual-Write Debezium 2.5 + Kafka Cluster @kido/async-sync Two-Phase Poller Production Gain
End-to-End Sync Latency (p50) $45\text{ ms}$ (Unreliable) $68\text{ ms}$ $18\text{ ms}$ 3.7x Faster
End-to-End Sync Latency (p99) Broken under network jitter $320\text{ ms}$ $42\text{ ms}$ 7.6x More Stable
Collector RAM Footprint N/A (Embedded in App) $2,480\text{ MB}$ (JVM + Kafka) $12.4\text{ MB}$ (Pure Bun Engine) 99.5% RAM Reduction
Write Reduction via LSN Coalescing $0%$ (Every update dispatched) Requires dedicated Flink job $78.4%$ (Over 100ms batch windows) Almost 4/5 Write Reduction
Data Drift Over 60s Queue Outage 100% Drift (6,000 lost events) 0% Drift (Durable) 0.0% Drift (Catch-up in 4.2s) Flawless Eventual Consistency

5. Architectural Conclusion & Engineering Principles

Implementing real-time state synchronization via PostgreSQL Logical WAL CDC permanently eradicates the data inconsistency and split-brain risks inherent to Application Dual-Write patterns.

By understanding the first-principles nature of cloud primitives, honoring the strict functional boundary between Message Queues (Upstream Shock Absorbers) and CDC Streams (Downstream State Mirrors), and employing Two-Phase Peek-then-Ack with LSN Burst Coalescing, software architects can achieve tier-1 enterprise durability and sub-50ms synchronization latency with near-zero resource overhead.


Explore the progressive 3-tier implementation of PostgreSQL CDC (Tier0TriggerNotifyOutboxCdc, Tier1NativeWalSlotPoller, and Tier2DebeziumEnvelopeConsumer) alongside 65+ battle-tested AI Agent Skills in the Agent Harness Kit on GitHub and the Agensi.io Skill Marketplace (khanhdp).

Multi-Temporal Tenancy & The 4-Layer Timezone Shield (Duolingo-Grade Architecture): Navigating Asia/Saigon ICU Traps, Cloudflare IP Spoofing, and Rolling Timezone Bucket Sweeps
Security, Auth & Access Control
Oct 15, 2026•14 min read

Multi-Temporal Tenancy & The 4-Layer Timezone Shield (Duolingo-Grade Architecture): Navigating Asia/Saigon ICU Traps, Cloudflare IP Spoofing, and Rolling Timezone Bucket Sweeps

Architectural analysis of global multi-timezone systems (Multi-Temporal Tenancy): Resolving Node.js ICU timezone traps, defeating Cloudflare IP header spoofing, and implementing O(1) rolling timezone bucket sweeps to eliminate midnight database thundering herds.

🎭Chronicles:Ep 87, Ep 128, Ep 129, Ep 130, Ep 131
Author & Systems ArchitectDa Nang, Vietnam

(Dương Phúc Khánh · Kido)

Senior Software Engineer & Systems Architect with 9+ years of experience in high-concurrency distributed architectures, Google Core Web Vitals optimization (0ms TBT, CLS 0.000), and author of the 148+ episode engineering war-story series Kido Chronicles.

⚡ Enterprise Architecture & Turnkey B2B Systems

Need High-Concurrency System Architecture Consulting, AI Automation, or Turnkey B2B Software?

Explore 35 production infrastructure combo suites (kido-packages (@kido/*)) and 18 turnkey B2B Web App/ERP systems with 100% source code ownership architected by (Dương Phúc Khánh · Kido).

Tags:#PostgreSQL#Change Data Capture#WAL#Message Queue#Search Synchronization#Distributed Systems