Runbook: Investigate Replication Lag
1 · Prerequisites
Confirm every item is in place before any state change.
- The alert payload: which standby, which metric, what threshold, and when it first fired
- A connection to the primary and a connection to the standby, because the two views answer different questions
- Knowledge of whether this standby is deliberately delayed, and whether it is synchronous
- The estate inventory of standbys, so an absent one can be distinguished from a slow one
- Knowledge of what changed recently: a bulk load, a network change, a standby reboot, or a new workload on the standby
2 · Pre-checks
Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.
- · Confirm the standby is present at all.
SELECT application_name, state FROM pg_stat_replication;on the primary. A standby that is not connected has no row, and every lag metric derived from this view returns NULL rather than a large number. - · Check what the monitoring did with that NULL. A system that renders NULL as zero draws a perfect lag graph for a standby that is not replicating at all. If the alert's metric could return NULL, establish what it displays before trusting the graph.
- · Read byte lag, not time lag.
SELECT application_name, state, pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS bytes_behind, write_lag, flush_lag, replay_lag FROM pg_stat_replication;Byte lag is meaningful whether or not the primary is committing. - · Check whether the primary is idle.
SELECT pg_current_wal_lsn();twice, thirty seconds apart. If it has not moved, a metric based on time since the last replayed commit will climb with the wall clock while the standby is perfectly current. - · Read the standby side.
SELECT pg_is_in_recovery(), pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn(), pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn()) AS unreplayed_bytes, (SELECT count(*) FROM pg_stat_wal_receiver) AS receiver; - · Check whether the standby is deliberately delayed.
SHOW recovery_min_apply_delay;A non-zero value means it is behind on purpose, and the correct alert threshold is that delay plus a margin. - · Check for recovery conflicts.
SELECT * FROM pg_stat_database_conflicts WHERE datname = current_database();on the standby. Conflicts mean queries on the standby are being cancelled, which is a different symptom from lag.
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1Classify the situation before diagnosing. There are four: the standby is absent; WAL is not arriving; WAL is arriving and not being applied; nothing is wrong and the metric is misleading. Each has a different fix and the classification takes two queries.
- 2If the standby is absent, look at the standby, not the primary.
SELECT * FROM pg_stat_wal_receiver;An empty result means the receiver process is not running — it cannot connect — as distinct from connected and stalled. The standby log will say why, once every five seconds. - 3**For a connection failure, check
primary_conninfofirst.**SHOW primary_conninfo;A literal IP address that no longer exists produces a standby that retries silently forever, logging aFATALnobody reads. - 4**If WAL is arriving but not being applied,
unreplayed_bytesis large.** Replay is single-threaded and can fall behind a heavy write workload, a large index build, or a conflicting query on the standby that keeps being retried. - 5Check what replay is waiting on.
SELECT pid, wait_event_type, wait_event, state FROM pg_stat_activity WHERE backend_type = 'startup';on the standby. ALockwait means a query on the standby is blocking recovery. - 6**Check
hot_standby_feedbackand its consequences.** With itoff, recovery cancels conflicting queries — visible inpg_stat_database_conflicts. With iton, recovery waits instead, and the standby holds the primary's vacuum horizon. Neither is wrong; the symptom differs. - 7If WAL is not arriving, check the network and the primary.
SELECT count(*) FROM pg_stat_replication;on the primary against the inventory, and the standby log forcould not receive data from WAL stream. - 8Check whether the slot is still holding.
SELECT slot_name, active, wal_status, safe_wal_size FROM pg_replication_slots;on the primary. Awal_statusother thanreservedmeans the primary can no longer guarantee the WAL this standby needs, and the standby may not be able to catch up at all. - 9**Check the primary's
pg_walsize.** A standby that has been absent for a long time with a slot in place will have caused WAL to accumulate, and that is a second incident hiding behind the first. - 10If the metric was misleading, fix the metric. Byte lag from the primary,
unreplayed_bytesfrom the standby, and an absence check against an inventory. Time since the last replayed commit is not usable on an idle primary. - 11Test the corrected alert by causing the condition.
SELECT pg_wal_replay_pause();on the standby makes it genuinely lag, harmlessly and on demand;pg_wal_replay_resume()ends it. An alert that has never fired has never been shown to work. - 12Record the classification, the cause, and what was changed.
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓
pg_stat_replicationon the primary shows the standby withstate = 'streaming'and a small, stable byte lag. - ✓
pg_stat_wal_receiveron the standby showsstatus = 'streaming'and names the expected sender. - ✓
unreplayed_byteson the standby is near zero, or is explained by a deliberaterecovery_min_apply_delay. - ✓A row written on the primary appears on the standby within seconds. This is the end-to-end check and it exercises the whole path rather than one view.
- ✓The corrected alert fires when replay is paused deliberately, and clears when it is resumed.
- ✓The absence alert fires when the standby is stopped deliberately, and clears when it is started.
- ✓The replication slot shows
wal_status = 'reserved'andpg_walon the primary has returned to its normal size.
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶This procedure reads. Rollback covers only the actions a responder takes by reflex.
- ↶If replay was paused for a test, resume it:
SELECT pg_wal_replay_resume();. A standby left paused falls further behind indefinitely and stops being a usable failover target. - ↶If
primary_conninfowas changed, it is reloadable —SELECT pg_reload_conf();— and reverting is the same operation. Confirm the receiver reconnected afterwards rather than assuming. - ↶If
hot_standby_feedbackwas changed, understand what the change moved. Turning it on stops query cancellations on the standby and starts holding the primary's vacuum horizon; turning it off does the reverse. Both are real trades. - ↶If a replication slot was dropped to relieve WAL pressure, the standby may no longer be able to catch up. Confirm it reconnected; if the WAL it needed has gone, rebuild it from a base backup.
- ↶If an alert was silenced during the investigation, restore it or shorten the silence. A muted replication alert is how a four-hour lag goes unnoticed.
6 · Escalation
When the runbook isn't enough, contact:
- · The standby has been disconnected for a long period and the WAL it needs no longer exists: escalate. It must be rebuilt from a base backup, and until then the estate has no failover target.
- · Replay cannot keep up with the primary's write rate: escalate to the platform owner. A standby on slower storage or with fewer resources than the primary will fall permanently behind, and that is a provisioning question.
- · The standby is synchronous and is holding commits: escalate to the incident owner. Every write on the primary is waiting, and the choice between waiting and clearing
synchronous_standby_namesis a durability decision. - · A slot has
wal_statusoflost: escalate. The primary has discarded WAL this slot needed and the standby cannot resume; a rebuild is the only path. - · The primary's
pg_walis growing because of this standby's slot: escalate before dropping the slot. Dropping it relieves the disk and commits you to rebuilding the standby. - · The alert has been firing regularly for months and has been muted: escalate as a monitoring defect rather than treating this occurrence. A metric that is loudest when the cluster is healthiest trains people to ignore it.
Four situations produce a replication lag alert, and only two of them are replication problems.
| Situation | pg_stat_replication | Standby | What to do |
|---|---|---|---|
| Standby absent | No row | Receiver not running | Look at the standby’s log |
| WAL not arriving | Row, state not streaming | receive_lsn static | Network, primary_conninfo, slot |
| WAL not applied | Row, streaming | replay_lsn < receive_lsn | Recovery conflict or replay throughput |
| Nothing wrong | Row, streaming, byte lag 0 | Caught up | Fix the metric |
The metric that is loudest when the cluster is healthiest
Absence before slowness
Two queries, two ends
-- on the primary
SELECT application_name, state,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS bytes_behind,
write_lag, flush_lag, replay_lag
FROM pg_stat_replication ORDER BY application_name;
-- on the standby — this one survives losing the primary
SELECT pg_is_in_recovery(),
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn(),
pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn()) AS unreplayed_bytes,
(SELECT count(*) FROM pg_stat_wal_receiver) AS receiver;
unreplayed_bytes separates WAL arriving but not applied from WAL not
arriving. A single lag number cannot express that difference, and the
two have entirely different causes.
Deliberately delayed is not lagging
SHOW recovery_min_apply_delay;
A delayed standby is behind on purpose — it gives an operator a
window to stop replay before a bad DELETE arrives. Measured with a
30-second delay: a new row was invisible at 9, 18 and 27 seconds and
visible at 36, while byte lag stayed at 0.
Model it as delayed, with its threshold set to the configured delay plus a margin. Excluding it from alerting means nobody is watching whether it is receiving WAL at all.
Blast radius
| Action | Reversible? | What it costs if wrong |
|---|---|---|
| Reading either view | Yes | Nothing |
pg_wal_replay_pause() | Yes, resume it | A standby left paused falls behind indefinitely |
Changing primary_conninfo | Yes, reload | A standby that cannot connect |
Changing hot_standby_feedback | Yes, reload | Either query cancellations, or the primary’s vacuum horizon |
| Dropping a slot | No | A standby that may need a full rebuild |
Test the alert by causing the condition
-- on the standby
SELECT pg_wal_replay_pause();
-- generate writes on the primary, wait for the page, then:
SELECT pg_wal_replay_resume();
Harmless, on demand, and it is the only way to know the alert works. There is no excuse for an untested replication alert — and an untested one is exactly what gets muted after a year of Sunday mornings.