PostgreSQL · advanced
PostgreSQL replica replay lag: distinguish write pressure from apply blockage
Replica replay lag on a PostgreSQL streaming replica is the gap between bytes received via WAL streaming and bytes replayed by the startup process. The editorial argument: replay lag is not one failure but two, and the right response depends on which one you are in. Write pressure pushes the receiver's write queue; apply blockage stalls the startup process on a lock, a slow query, or a missing replica identity. Conflating them leads to misallocated mitigation.
The symptoms
- •pg_stat_replication.replay_lag or replay_lsn on the primary climbs steadily while replica's pg_last_wal_replay_lsn lags behind pg_last_wal_receive_lsn, indicating replay cannot keep up with receive.
- •Replica's pg_stat_replication on a cascading standby shows the upstream's send/flush/replay LSNs diverging, often while CPU and disk write latency on the replica remain low.
- •Application reads from the replica return stale data with increasing staleness, while primary writes succeed normally and other replicas stay current.
- •System-level symptoms differ by mode: high WAL receiver disk write throughput with low startup process CPU points to write pressure; high startup process CPU or visible waits on AccessShareLock or RowExclusiveLock on hot tables point to apply blockage.
- •Recovery is reportedly finished ("consistent recovery state reached") yet replay continues to fall behind, suggesting apply starvation rather than initial catch-up.
- •Long-running transactions or replication slot lag warnings appear without a corresponding spike in writes, pointing at a held-back apply.
Likely causes
- •Write pressure: WAL volume generated on the primary exceeds the replica's sustained fsync and write throughput on its pg_wal directory, even though apply is otherwise idle.
- •Apply blockage: the startup process is blocked on a lock held by a long-running query, a DDL, or a conflicting VACUUM FULL/REINDEX on a table involved in the replay path.
- •Missing or invalid replica identity: UPDATE/DELETE on a table without a replica identity forces the apply process to scan, and a missing index on the replica identity columns can stall apply for the affected table.
- •Synchronous commit configuration mismatch: synchronous_standby_names or synchronous_commit settings cause the primary to stall on commit, indirectly inflating apparent receive-side backpressure.
- •Long-running autovacuum, or a stuck replication slot on another consumer, holding back xmin so that tuples cannot be cleaned up and apply slows.
- •I/O starvation on the replica's WAL volume (different filesystem or slower device than data) producing fsync backpressure independent of the data volume.
First ten minutes
- 01Confirm the topology: identify the primary and the affected replica(s) via pg_stat_replication on the primary, recording client_addr, state, sync_state, and the three LSNs (sent, flush, replay).
- 02Separate receive from replay: on the replica, compare pg_last_wal_receive_lsn() to pg_last_wal_replay_lsn(); a stable gap is replay lag, a growing receive-side gap indicates primary-to-replica transport or write pressure.
- 03Classify the mode using process-level evidence: observe the WAL receiver process for sustained disk writes (write pressure) and the startup process for CPU activity and wait events (apply work).
- 04Inspect wait events on the startup process; any Lock waits, LWLock waits, or DataFileRead waits indicate apply-side contention rather than raw throughput.
- 05Check pg_replication_slots for active, inactive-but-retained, and aborted slots, and any replication slot's confirmed_flush_lsn relative to the current WAL position.
- 06Snapshot long-running queries and transactions on the replica that touch hot tables; an open xmin that pre-dates the lag onset is the strongest signal of apply blockage.
Evidence to collect
- •pg_stat_replication rows on the primary with sent_lsn, flush_lsn, replay_lsn, replay_lag, sync_state, and state.
- •pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn(), pg_last_xact_replay_timestamp() on the replica, sampled at fixed intervals to show the gap widening.
- •pg_stat_activity on the replica filtered to backend type "startup" plus any sessions whose xact_start pre-dates the lag onset, capturing wait_event_type and wait_event.
- •pg_replication_slots including active, active_pid, and confirmed_flush_lsn; if WAL retention is growing on the primary, slot lag is implicated.
- •Per-table replica identity declarations on the primary for tables involved in the workload, plus presence of indexes on the replica identity columns on the replica.
- •I/O and fsync latency evidence on the replica's WAL volume, scoped to the WAL receiver's writes versus the startup process's reads.
Where to look
- •Boundary between WAL transport and WAL apply: the gap between pg_last_wal_receive_lsn() and pg_last_wal_replay_lsn() is the only place the two modes separate cleanly.
- •Boundary at the startup process: its wait_event and wait_event_type are the apply-side diagnostic surface; a Lock, LWLock, or DataFileRead wait is apply-side contention.
- •Boundary at WAL persistence on the replica: WAL receiver write activity versus fsync latency on the pg_wal directory is the write-pressure surface.
- •Boundary at replication slots: pg_replication_slots on the primary determines WAL retention; if WAL cannot be removed, receive-side stalls can cascade into primary bloat even when apply is healthy.
- •Boundary at lock holders: any session on the replica whose xact_start pre-dates the lag onset is the candidate apply blocker, especially if its query targets a hot table.
Diagnostic steps
- 01Sample the three LSNs on the replica at fixed intervals; a stable receive/replay gap localizes the problem to apply, while a widening gap localizes it to transport or write throughput.
- 02On the replica, query pg_stat_activity where backend_type = 'startup' and record wait_event_type and wait_event; a Lock wait on AccessShareLock or RowExclusiveLock identifies a blocking query by relation.
- 03Cross-reference any blocking session's xact_start with the lag onset time; a pre-existing transaction that has not advanced is the primary apply blocker candidate.
- 04On the primary, list pg_replication_slots; a slot whose confirmed_flush_lsn trails current WAL by more than expected indicates that consumer's apply is slow, which can mask replica-apply problems.
- 05For each table in the replay-path workload, verify on the replica that REPLICA IDENTITY matches the primary and that the replica identity columns are indexed; missing indexes turn apply into a sequential scan.
- 06Confirm replica-side write pressure is not I/O-starved: sustained high WAL write throughput with elevated fsync latency on pg_wal indicates write pressure even when apply is idle.
- 07Compare synchronous_commit and synchronous_standby_names on the primary against the deployed topology; a synchronous standby whose apply is slow becomes the system's bottleneck and looks like replica apply lag.
Common mistakes
- •Reading replay_lag in pg_stat_replication as a single number without also tracking receive-vs-replay LSNs, which prevents distinguishing write pressure from apply blockage.
- •Restarting the replica to "fix" apply blockage: a restart closes connections and clears long-running queries but does not address the original lock holder or missing replica identity index, so lag returns.
- •Dropping or advancing a replication slot to recover WAL space when slot lag is itself a symptom of a slow apply consumer; this can break other replicas.
- •Assuming a missing index on the primary is the cause of replica apply lag; replica apply depends on the replica's indexes on replica identity columns, not the primary's.
- •Conflating replica replay lag with logical replication apply lag, which uses a different worker model and a different lag surface (pg_stat_subscription).
- •Increasing wal_compression or full_page_writes as a mitigation; both affect transport volume and primary-side cost, not the replica's apply throughput.
Safe fixes
- •If the startup process is waiting on a lock held by a pre-existing query that is not critical, end that session with pg_terminate_backend(<pid>) where <pid> is obtained from pg_stat_activity on the replica; only do this after confirming the query is non-essential and its xact_start pre-dates the lag onset.
- •If REPLICA IDENTITY is DEFAULT on a table receiving UPDATEs and DELETEs, set REPLICA IDENTITY to a usable indexed column set on the primary and ensure the replica has an index on those columns; this only narrows apply cost when verified with EXPLAIN on the replica.
- •If WAL volume is the bottleneck, reduce per-transaction WAL bytes on the primary (smaller row width, fewer indexed updates, batched writes) and verify with a measured drop in pg_stat_replication.write_lsn delta per second.
- •If a replication slot is the bottleneck because its consumer cannot keep up, add capacity for that consumer first; only consider removing the slot after consumers are healthy.
- •If synchronous_commit is forcing the primary to wait on a slow replica, separate the synchronous peer from the lag-affected replica in synchronous_standby_names so the slow replica is no longer on the synchronous path.
- •If a stuck autovacuum is holding back xmin on the replica, identify and cancel only after confirming via pg_stat_progress_vacuum that progress has stalled; do not disable autovacuum globally.
Prove the fix
- 01Replay gap closed: pg_last_wal_replay_lsn() advances within seconds of pg_last_wal_receive_lsn() on the affected replica for at least the duration of a representative workload.
- 02Startup process wait events return to short DataFileRead or walreceiver-related waits with no sustained Lock waits, sampled over multiple intervals.
- 03Replication slot confirmed_flush_lsn advances at the same pace as WAL generation on the primary, ruling out slot-induced retention pressure.
- 04Application read-staleness observed during the incident (a known write-then-read round-trip) returns to baseline round-trip latency and value freshness on the affected replica.
- 05No regression on the primary: write latency, fsync latency, and WAL retention remain within prior bands after any configuration change, verified by the same monitoring sources used before the change.
Prevention and next steps
- •Track both the receive/replay LSN gap and the startup process wait event as first-class signals, with separate alerting thresholds so write pressure and apply blockage are distinguishable in dashboards.
- •Enforce that every table receiving UPDATE/DELETE has an explicit REPLICA IDENTITY with a matching indexed column set on every replica; detect drift between primary and replica catalog views.
- •Cap transaction age on replicas: alert on any session with xact_start older than a defined budget, since pre-existing transactions are the dominant apply-blockage cause.
- •Size WAL volume and fsync capacity on replicas to peak WAL generation rate of the primary, and verify under load rather than assuming parity; track fsync latency on the WAL volume as a distinct signal from data volume latency.
- •Review replication slot ownership and purpose regularly; each slot represents a consumer whose apply health must be monitored independently.
Safe commands and checks
psql -d postgres -c "SELECT client_addr, state, sync_state, sent_lsn, flush_lsn, replay_lsn, replay_lag FROM pg_stat_replication;" psql -d postgres -c "SELECT pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn(), pg_last_xact_replay_timestamp();" psql -d postgres -c "SELECT pid, datname, usename, application_name, xact_start, wait_event_type, wait_event FROM pg_stat_activity WHERE backend_type = 'startup' OR xact_start < now() - interval '5 minutes';" psql -d postgres -c "SELECT slot_name, plugin, active, active_pid, confirmed_flush_lsn FROM pg_replication_slots;" psql -d postgres -c "SELECT relname, relreplident FROM pg_class WHERE relreplident <> 'f' AND relkind = 'r' ORDER BY relname;" psql -d postgres -c "SELECT pid, datname, usename, application_name, state, xact_start, query FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY xact_start LIMIT 10;" psql -d postgres -c "SELECT pg_terminate_backend(<pid>);" with <pid> obtained from the previous SELECT and only after confirming the session is non-essential and its xact_start pre-dates the lag onset.