MVP Factory
ai startup development

PostgreSQL replication slot bloat: WAL retention, lag, and disk exhaustion

KW
Krystian Wiewiór · · 4 min read

Meta description: How logical replication slot lag silently retains WAL, stalls autovacuum, and fills your disk — and how to architect a topology that degrades gracefully.


TL;DR — Logical replication slots hold WAL segments until subscribers acknowledge them. A lagging or disconnected subscriber silently accumulates gigabytes of retained WAL, stalls autovacuum, and causes table bloat that cascades under traffic spikes. The fix is slot-aware monitoring, max_slot_wal_keep_size enforcement, and a routing topology designed to degrade gracefully before it fails catastrophically.


Why your read layer is the first thing that buckles

It is 3 AM. Your on-call engineer wakes to a disk exhaustion alert on the primary. The write path is stalled. Replicas are falling further behind by the second. The post-mortem will show the same thing it always shows: a logical replication slot fell behind six weeks ago, WAL retention crept up silently, and no alert fired until the disk hit 95%.

Here is what most teams get wrong about this: they instrument replica lag in seconds and consider the job done. Seconds of lag is not a disk metric. Bytes retained per slot is.


How WAL retention and slot lag interact

When you create a logical replication slot, PostgreSQL guarantees WAL retention from that slot’s restart_lsn forward. If your subscriber falls behind — or disconnects — PostgreSQL will not reclaim those WAL segments.

-- Check slot lag right now
SELECT slot_name,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal,
       active,
       confirmed_flush_lsn
FROM pg_replication_slots;

A subscriber disconnected for two hours on a 500 MB/s WAL generation workload retains roughly 3.6 TB. Your disk alarm fires long before anyone notices.


The slot bloat and autovacuum compound problem

Slot bloat does not just fill disk. It interacts dangerously with autovacuum through the xmin horizon. A lagging slot prevents PostgreSQL from removing dead tuples newer than the oldest transaction ID visible to that slot. The result, at 500 MB/s WAL generation:

Subscriber StateApprox. WAL RetainedTable Bloat RiskAutovacuum Impact
10s lag~5 GBLowNegligible
5min lag~150 GBMediumModerate xmin hold
30min lag~900 GBHighSignificant bloat
Disconnected 2h~3.6 TBCriticalAutovacuum effectively stalled

Lag is not a warning — it is a compound failure initiator. Dead tuples accumulate, index scans degrade, and query plans that looked fast in staging become sequential scans under production load.


Slot-aware autovacuum tuning

Default autovacuum settings are insufficient for tables under active replication pressure. In production systems I have worked on, the following changes prevent bloat accumulation during lag events:

-- Per-table tuning for high-write tables
ALTER TABLE events SET (
  autovacuum_vacuum_scale_factor = 0.01,
  autovacuum_analyze_scale_factor = 0.005,
  autovacuum_vacuum_cost_delay = 2
);

Set a hard WAL retention cap via max_slot_wal_keep_size (PostgreSQL 13+):

# postgresql.conf
max_slot_wal_keep_size = '10GB'

This invalidates the slot when the limit is hit, forcing subscriber reconnection — far preferable to disk exhaustion taking down your primary.


A topology that degrades gracefully under load

The right topology routes reads based on both lag and slot presence. Instrument restart_lsn delta as a first-class metric and wire it into your routing health checks before you need it:

Primary ──► Replica A (streaming, no slot)  ──► Bulk exports, analytics
       └──► Replica B (slot, lag < 100ms)   ──► CDC pipelines only
       └──► Replica C (streaming, no slot)  ──► User-facing queries

User-facing replicas stream WAL without slots — zero slot bloat risk. Slots go on dedicated nodes with independent disk volumes, isolated from everything serving user traffic. Route dynamically using HAProxy or PgBouncer health checks that return healthy only when replica_lag < threshold.

-- Alert before lag becomes a disk event
SELECT slot_name,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots
WHERE pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) > 5368709120; -- 5GB

The 3 AM incident is a monitoring gap, not a technical inevitability. Teams that track bytes retained per slot — not seconds of lag — see the failure coming days out, not seconds after the disk fills.


3 actionable takeaways

  1. Set max_slot_wal_keep_size immediately on any PostgreSQL 13+ instance with logical replication slots. Without it, a disconnected subscriber will fill your disk and take down your primary with no warning.

  2. Monitor WAL retained in bytes per slot, not lag in seconds. Bytes map directly to disk headroom. Seconds are an abstraction that hides the actual failure mode until it is too late.

  3. Isolate logical replication slots to dedicated replica nodes with independent disk volumes. Stream WAL slotlessly to every replica serving user traffic.


Tags: backend, architecture, postgresql, replication, devops


Share: Twitter LinkedIn