Estuary

Does CDC Slow Down a Production Database? The Real Cost of Log-Based CDC

See where PostgreSQL CDC adds overhead, from WAL and logical decoding to replication slots, vacuum pressure, and production query performance.

CDC Database Performance
Share this article

The question behind CDC database performance is whether enabling change data capture will slow the production primary. Log-based CDC avoids repeated scans of application tables during steady-state capture. It can run with low direct impact, but decoding and additional log writes still consume resources. The effect on application performance depends on the workload, configuration, and available capacity.

PostgreSQL is the worked example throughout. To assess PostgreSQL CDC performance, separate three layers: normal decoding work, configuration-dependent overhead, and failure conditions such as stalled consumers or blocked vacuum cleanup. The initial read of existing rows has a different workload, covered in CDC backfills.

Key Takeaways

  • Steady-state log-based CDC avoids repeated application-table scans. An active PostgreSQL capture still consumes CPU, memory, disk I/O, and network capacity.

  • wal_level = logical adds WAL information. REPLICA IDENTITY FULL increases the old-row data logged for updates and deletes, especially on wide, busy tables.

  • A published table without a usable replica identity rejects updates or deletes when the publication publishes those operations. Inserts do not require a replica identity.

  • Narrowing a publication reduces emitted changes and downstream work. It does not stop the decoder reading unrelated WAL or remove a slot's WAL-retention requirements.

  • A lagging logical slot retains WAL and system-catalog rows. Ordinary steady-state logical-slot lag does not by itself retain dead rows in user tables.

  • Old transactions, prepared transactions, and standby feedback can prevent vacuum from removing dead user-table rows. The resulting bloat can increase query latency and CPU use.

  • PostgreSQL 16 and later support decoding on a standby. This moves decoding work off the primary, but standby feedback can still cause bloat there.

Does CDC slow down a production database?

In steady state, log-based CDC can have low direct impact because it reads the transaction log instead of repeatedly scanning application tables. It does not guarantee unchanged application latency: decoding uses CPU and I/O, and the database writes additional WAL information. A primary with little spare capacity can experience contention even while capture is keeping up.

Change data capture performance impact also depends on configuration and retention. Logging full old rows makes updates and deletes more expensive. A stalled consumer leaves its slot retaining required WAL. Transactions and standby feedback that hold an old vacuum horizon block cleanup of the row versions they protect. These are separate costs from the normal decoding work described in the PostgreSQL CDC guide.

PostgreSQL CDC overhead from decoding, WAL configuration, and failure conditions
Where PostgreSQL CDC adds load during normal operation, configuration changes, and failure conditions

What does log-based CDC cost in steady state?

The CDC database overhead in steady state includes CPU to decode WAL, memory to buffer changes, disk reads and possible spill writes, network traffic, and additional WAL generation. There is no fixed percentage that applies across workloads.

For capture from the primary, PostgreSQL runs one walsender process per active streaming connection. The process reads write-ahead log (WAL) records, reconstructs transactions, and sends changes to the consumer. The standard output plugin, pgoutput, filters the decoded changes by publication. A publication specifies which tables and operations to emit. Filtering happens after decoding, so capturing a small table does not make the decoder skip WAL generated elsewhere on the server.

max_wal_senders, defaulting to 10, limits the number of walsender connections. Each logical streaming connection has its own buffer for decoded changes. logical_decoding_work_mem, defaulting to 64 MB, sets the threshold for that buffer across all transactions being decoded by the connection. Other allocations, including transaction bookkeeping, mean the process can use more memory than this setting.

When buffered changes reach the threshold, PostgreSQL can spill them to disk and read them back for processing. One large transaction or several concurrent transactions can cause spilling. From PostgreSQL 14, a client using pgoutput can instead enable streaming of in-progress transactions with protocol version 2 or later. The client must support and enable this option. A large bulk update can therefore create substantial memory pressure, spill I/O, and network traffic even when the capture is healthy.

ResourceMechanismBoundWhere to see it
ProcessOne walsender per replication connectionmax_wal_senders, default 10pg_stat_replicationpg_replication_slots.active_pid
CPUReading WAL, decoding the slot's database, then filtering by publicationWorkload-dependentThe active_pid process in OS tooling
MemoryBuffered changes and other process allocationsBuffer threshold: logical_decoding_work_mem, default 64 MB per connectionProcess memory; SHOW logical_decoding_work_mem for the setting
Disk I/OReading WAL and writing/reading spill files when combined buffered changes reach the thresholdSpill files can continue growing as changes accumulateHost/process I/O metrics; spill_txns, spill_count, and spill_bytes for spill activity
NetworkThe decoded stream to the consumerNetwork capacity and consumer read rateNetwork byte counters
Extra WALwal_level = logical records additional informationGrows with REPLICA IDENTITY FULL and update/delete volumeChanges in pg_stat_wal.wal_bytes or primary WAL positions over a measured interval

Steady-state log decoding avoids repeated application-table scans and per-row capture triggers. The additional WAL information still has to be generated and written. With asynchronous capture, application commits do not wait for the CDC consumer's acknowledgment. A logical subscription configured for synchronous replication can add that wait.

Which PostgreSQL settings increase CDC overhead?

wal_level = logical

Setting wal_level to logical adds information needed for logical decoding to the WAL, whether or not a slot exists. PostgreSQL identifies heavy update/delete traffic with REPLICA IDENTITY FULL as a workload where the increase is particularly significant.

REPLICA IDENTITY FULL is the clearest concrete cost

A replica identity tells PostgreSQL what old-row information to include so that a downstream system can identify the row affected by an update or delete. By default, PostgreSQL uses the primary key. With REPLICA IDENTITY FULL, it includes every column of the old row. On a wide table with frequent updates or deletes, this increases WAL volume, decoding work, and the size of change events. For Estuary's PostgreSQL connector, evaluate FULL's performance impact per table.

A table without a primary key can use an eligible unique index explicitly configured as its replica identity. The index must be non-partial, non-deferrable, and contain only columns marked NOT NULL. If no suitable key exists, FULL is the fallback.

A table without a primary key left at REPLICA IDENTITY DEFAULT behaves as if its replica identity were NOTHING. Without a usable replica identity, PostgreSQL rejects an update or delete when the table belongs to a publication that publishes that operation. The application receives an error on the publisher. Inserts do not require a replica identity.

Native PostgreSQL subscriptions also have to find the matching row on the subscriber. With FULL, a suitable subscriber index can improve this search; without one, matching can be inefficient. A CDC platform that writes to a keyed destination may handle matching differently.

Aurora PostgreSQL has an additional setting, aurora.enhanced_logical_replication, required for its zero-ETL integrations. It logs all column values even without FULL and can increase source IOPS. Enabling or disabling it invalidates existing logical replication slots.

Publication breadth

A publication controls which decoded changes the plugin emits. FOR ALL TABLES includes every eligible table, including tables created later. Emitting changes from tables the pipeline does not need adds network traffic and downstream processing. Estuary's PostgreSQL connector should use a publication that contains only the tables being captured.

Narrowing the publication reduces emitted changes. It does not stop the walsender reading the server's WAL or decoding changes for the slot's database. WAL retention is governed by the slot's required position in the cluster-wide log, not by the size or activity of a captured table.

Publication scope also affects application writes. Any included table without a usable replica identity rejects updates or deletes if the publication publishes those operations, even if the capture does not need that table.

Each additional consumer

A logical replication slot tracks a consumer's progress and retains the WAL and catalog rows it still needs. Each active logical streaming connection has its own walsender and decoding buffer. Independent slots do not share decoded output, so two active consumers using separate slots decode changes independently. Each slot retains its own position and retention requirements even when inactive; an inactive slot has no running walsender. The oldest required WAL position across slots determines what they prevent PostgreSQL from removing.

What happens when the slot or the vacuum horizon stops moving?

A stalled consumer leaves its slot retaining required WAL files. An old vacuum horizon prevents PostgreSQL from cleaning up the obsolete row versions it protects. The horizon is the oldest transaction boundary that vacuum must preserve for readers or replication. Obsolete row versions are called dead tuples. System catalogs hold metadata about the database, such as table definitions; their retention requirements differ from those of application tables.

Log sequence numbers (LSNs) identify positions in WAL. A logical slot's restart_lsn identifies the oldest WAL it still needs. Its catalog_xmin protects catalog rows needed to interpret older WAL. Neither position should be confused with the regular vacuum horizon that governs cleanup of dead user-table rows.

HorizonWhat it protectsWho moves itColumn in pg_replication_slotsFailure when held back
WAL retentionWAL files in pg_walPostgreSQL advances the required position as decoding and consumer acknowledgments permitrestart_lsnDisk can fill; exceeding max_slot_wal_keep_size at a checkpoint can allow required WAL removal and invalidate the slot
Catalog vacuum horizonDead rows in the system catalogs that decoding needs to interpret older WALThe slot, as decoding advancescatalog_xminCatalog bloat; in the extreme, wraparound protection
Regular vacuum horizonDead rows in user tablesA transaction retaining an old transaction ID or snapshot, a prepared transaction, or standby feedbackxmin for a physical slot carrying feedback; transaction-held horizons also require session and prepared-transaction viewsTable and index bloat, which can increase query latency and CPU

A consumer that stops acknowledging, a crashed connector, or an orphaned slot can prevent restart_lsn from advancing. Retained WAL can fill the disk. max_slot_wal_keep_size is evaluated at checkpoints; exceeding it can allow required WAL to be removed and invalidate the slot. It is not an immediate hard cap on disk usage. Estuary's replication slots guide covers sizing, health checks, and recovery.

A slot also protects old system-catalog rows needed for decoding through catalog_xmin. This can cause catalog bloat and, in extreme cases, transaction-ID wraparound protection. It does not by itself retain dead rows in user tables.

User-table cleanup can be held back by transactions retaining old transaction IDs or snapshots, prepared transactions, or standby feedback. When standby feedback is carried through a physical replication slot, the slot preserves the requested user-table horizon in its xmin field. Creating a logical slot with a data snapshot for export or use in the creating transaction temporarily protects the data horizon as well. That startup behavior is separate from steady-state logical-slot lag.

With max_slot_wal_keep_size at its default of -1, that setting does not limit the WAL a stalled slot can retain. Other configuration errors have different effects: missing standby feedback can invalidate a logical slot after required catalog rows are removed, while publishing updates and deletes for tables without usable replica identities makes those operations fail.

  • pg_stat_user_tables on the primary: inspect n_dead_tup, an estimate of dead rows, and last_autovacuum on frequently updated tables. Compare them with retained horizons and observed query latency.

pg_stat_replication adds sent_lsn, acknowledgment-lag columns, and backend_xmin for feedback held by a walsender. When a physical slot carries feedback, inspect its xmin as well.

Measure WAL on the correct host

Measure generation on the primary using changes in pg_stat_wal.wal_bytes or primary WAL positions, divided by elapsed time. Growth of the pg_wal directory also depends on retention and recycling. Estuary's WAL-throughput guide covers sampling and calculations.

For capture from the primary, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) gives the distance from the oldest required WAL to the current write position. For a logical slot on a standby, pg_wal_lsn_diff(pg_last_wal_replay_lsn(), restart_lsn) measures the distance through WAL already replayed there; it excludes received but unreplayed WAL and WAL not yet received. Neither expression measures exact directory size. pg_current_wal_lsn() cannot run during recovery; use the standby's receive or replay positions for standby measurements and inspect the upstream physical slot separately on the primary.

What Estuary's PostgreSQL capture asks of the source

Estuary's PostgreSQL capture uses one logical slot and one publication. In its default mode, it writes to a small watermarks table to coordinate backfill reads with concurrent changes. These writes also provide events for acknowledgments when captured application tables are idle, so default mode needs no separate heartbeat table.

Read-only capture removes the watermark writes. It requires frequent changes to at least one captured table, or a captured heartbeat table maintained by the customer, to keep acknowledgment progress moving. Estuary uses this mode for capture from PostgreSQL 16 or later standbys, with hot_standby_feedback enabled. For standby capture, heartbeat writes must happen on the primary.

For a table without a primary key, Estuary can use a suitable unique secondary index as the collection key. A materialization, the task that writes collection data to a destination, can group by a different key. Destination matching therefore differs from a native PostgreSQL subscription using FULL.

Captured changes go into durable collections before destination delivery. A materialization outage does not by itself stop capture or its acknowledgments to PostgreSQL. Acknowledgments advance confirmed_flush_lsn; PostgreSQL advances restart_lsn when older WAL is no longer required. A capture can run with no materialization, and a destination added later can read the retained collection history.

That history is subject to the storage mapping's retention policy. The default trial bucket keeps 20 days of collection data; production workflows should use a customer-owned bucket. If required unread data expires, collection replay alone cannot catch the destination up, and rebuilding may require rereading the source. A source snapshot can restore current state but cannot recreate expired intermediate changes that the source no longer retains.

Destination isolation also cannot restore a slot that PostgreSQL has invalidated. The capture will fail and require manual recovery.

Conclusion

In steady state, log-based CDC reads WAL rather than repeatedly scanning application tables. Decoding and additional WAL generation consume resources and can affect application performance. Configuration changes that cost: REPLICA IDENTITY FULL logs more old-row data, especially on wide, busy tables. Stalled consumers leave their slots retaining required WAL, while an old regular vacuum horizon prevents cleanup of the dead user-table rows it protects. Compare application performance and resource use under equivalent workloads, and monitor both decoding activity and retention.

Start streaming your data for free

Build a Pipeline

About the author

Picture of Emily Lucek
Emily LucekDeveloper Advocate / Data Engineer

Emily is an engineer and technical content creator with an interest in developer education. At Estuary, she works with data pipelines for both streaming and batch data and finds satisfaction in transforming a mess of information into usable data. Previous roles familiarized her with FinTech data and working closely with REST APIs.

Streaming Pipelines.
Simple to Deploy.
Simply Priced.
$0.50/GB of data moved + $.14/connector/hour;
50% less than competing ETL/ELT solutions;
<100ms latency on streaming sinks/sources.