DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on Originally published at mvpfactory.io

Vertical Scaling PostgreSQL Read Replicas with Logical Replication Slots

---
title: "PostgreSQL Replication Slot Bloat: WAL Retention, Lag, and the Topology That Survives Traffic Spikes"
published: true
description: "How logical replication slot lag silently retains WAL, stalls autovacuum, and fills your disk — and how to architect a read replica topology that degrades gracefully before it fails catastrophically."
tags: postgresql, architecture, devops, performance
canonical_url: https://mvpfactory.co/blog/postgresql-replication-slot-bloat-wal-retention
---

## What You Will Learn

In this workshop, you will understand how PostgreSQL logical replication slots silently accumulate WAL, how that interacts with autovacuum to cause cascading table bloat, and how to architect a read replica topology that degrades gracefully under load. By the end, you will have slot-aware monitoring queries, autovacuum tuning SQL, and a routing topology you can copy straight into your next production system.

## Prerequisites

- PostgreSQL 13+ (required for `max_slot_wal_keep_size`)
- At least one logical replication slot in use
- Access to `pg_replication_slots` on your primary
- Basic familiarity with WAL and streaming replication concepts

---

## The Problem Nobody Monitors Until 3 AM

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: they instrument replica lag in seconds and consider the job done. Seconds of lag is not a disk metric. **Bytes retained per slot is.**

(Side note — if you are the kind of engineer grinding through incidents like this, I have been using [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk) for desk exercise reminders during long on-call stretches. Your back will thank you eventually.)

---

## Step 1: Check Your Slot Lag Right Now

Run this on your primary before anything else:

Enter fullscreen mode Exit fullscreen mode


sql
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;


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**. A subscriber disconnected for two hours on a 500 MB/s WAL generation workload retains roughly 3.6 TB.

---

## Step 2: Understand the Compound Failure

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.

| Subscriber State | Approx. WAL Retained | Table Bloat Risk | Autovacuum Impact |
|---|---|---|---|
| 10s lag | ~5 GB | Low | Negligible |
| 5min lag | ~150 GB | Medium | Moderate xmin hold |
| 30min lag | ~900 GB | High | Significant bloat |
| Disconnected 2h | ~3.6 TB | Critical | Autovacuum 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.

---

## Step 3: Tune Autovacuum for Replication Pressure

Default autovacuum settings are insufficient for tables under active replication pressure. Here is the minimal setup to get this working on high-write tables:

Enter fullscreen mode Exit fullscreen mode


sql
ALTER TABLE events SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_analyze_scale_factor = 0.005,
autovacuum_vacuum_cost_delay = 2
);


Then set a hard WAL retention cap in `postgresql.conf`:

Enter fullscreen mode Exit fullscreen mode


ini
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.

---

## Step 4: Architect a Topology That Degrades Gracefully

Let me show you a pattern I use in every project. Route reads based on both lag *and* slot presence:

Enter fullscreen mode Exit fullscreen mode


plaintext
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 live on dedicated nodes with independent disk volumes, completely isolated from user traffic. Route dynamically using HAProxy or PgBouncer health checks that return healthy only when `replica_lag < threshold`.

Wire this alert before you need it:

Enter fullscreen mode Exit fullscreen mode


sql
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


---

## Gotchas

**Lag in seconds hides the actual failure mode.** A fast network with a slow consumer still accumulates bytes. Always alert on `retained_wal` size — seconds are an abstraction that hides the disk failure until it is too late.

**`max_slot_wal_keep_size` is not set by default.** The docs do not mention this loudly enough: without it on PostgreSQL 13+, a disconnected subscriber will fill your disk with zero warning. Set it immediately on every instance with logical slots.

**Slots on user-facing replicas are a ticking clock.** Any slot on a replica serving queries is a disk exhaustion risk. Isolate slots to dedicated nodes, always.

**Autovacuum cannot save you here.** Even aggressive per-table tuning cannot remove dead tuples blocked by the `xmin` horizon from a lagging slot. The topology fix and the `max_slot_wal_keep_size` cap are non-negotiable.

---

## Conclusion

The 3 AM incident is a monitoring gap, not a technical inevitability. Three things to do today:

1. **Set `max_slot_wal_keep_size`** on any PostgreSQL 13+ instance with logical replication slots.
2. **Monitor WAL retained in bytes per slot**, not lag in seconds — bytes map directly to disk headroom.
3. **Isolate logical replication slots** to dedicated replica nodes with independent disk volumes, away from anything serving user traffic.

Teams that track bytes retained per slot see the failure coming days out — not seconds after the disk fills.

---

*Resources: [PostgreSQL replication slots docs](https://www.postgresql.org/docs/current/warm-standby.html#STREAMING-REPLICATION-SLOTS) · [max_slot_wal_keep_size reference](https://www.postgresql.org/docs/13/runtime-config-replication.html)*
Enter fullscreen mode Exit fullscreen mode

Top comments (0)