Read replication lag as three numbers
pg_stat_replication reports write, flush and replay lag separately. A healthy standby showed 346, 356 and 427 microseconds — and which of the three grows during an incident tells you whether the problem is the network, the standby's disk, or replay itself.
Problem
What you're actually looking at
The symptom as it shows up on a real server.
Replication lag is usually reduced to a single number, which is why it so rarely leads anywhere. PostgreSQL exposes three, corresponding to three different journeys a WAL record makes: arriving at the standby, being made durable there, and being applied so queries can see it. Each has a different cause and a different fix, and collapsing them hides the answer.
Meridian's dashboard shows the replica lagging. Nobody can say whether the WAL has not arrived, has not been flushed, or has arrived and cannot be applied.
In plain English
A change on the primary makes three separate journeys to reach a replica: it has to arrive, it has to be written down safely, and it has to be applied so queries can see it. PostgreSQL times all three. If only the first is slow, the network or the sender is the problem. If the second is slow, the replica's disk is. If the third is slow while the first two are fine, the data is already there and something on the replica is blocking it from being applied. One combined number would hide all of that.
Before you start
- • A primary with at least one connected streaming standby.
- • Access to run queries on the standby as well as the primary.
How to identify it
- ›A single lag figure is alerting and there is no agreement on what it measures.
- ›pg_stat_replication has separate write_lag, flush_lag and replay_lag columns nobody reads.
- ›Long-running queries on the standby are suspected but not confirmed.
- ›You cannot tell whether the standby has the data and cannot apply it, or does not have it.
Pitfalls to avoid
- ✕Do not alert on one lag number; alert on the three separately or you cannot act on the alert.
- ✕Do not read lag as null and assume trouble — an idle primary leaves the columns null because there is nothing to acknowledge.
- ✕Do not compare lag in bytes and lag in time as if they were the same thing; a large byte gap on a fast link is often less serious than a small one that is not moving.
- ✕Do not fix replay lag by enabling hot_standby_feedback without accepting the trade: it stops replica query cancellations by letting dead rows accumulate on the primary.
- ✕Do not measure lag only on the primary; if streaming has stopped entirely there is no row in pg_stat_replication at all.
Trace it
- 01
Diagnose: capture the healthy three-lag baseline
A standby keeping up: 346 microseconds to acknowledge the write, 356 to flush, 427 to replay, and zero bytes between sent and replayed. Capturing this while things are calm is what makes the incident reading meaningful.
SELECT application_name, state, sync_state, write_lag, flush_lag, replay_lag, pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replay_bytes_behind FROM pg_stat_replication;Captured live on PostgreSQL 17.11 (docker/labs/ha)application_name | state | sync_state | write_lag | flush_lag | replay_lag | replay_bytes_behind ------------------+-----------+------------+-----------------+-----------------+-----------------+--------------------- standby1 | streaming | async | 00:00:00.000346 | 00:00:00.000356 | 00:00:00.000427 | 0 (1 row) - 02
Diagnose: read the shape, not the size
Which column grows is the diagnosis. write_lag alone points at the network or the WAL sender. flush_lag growing past write_lag points at the standby's disk. replay_lag growing while the other two stay flat means the WAL is already there and replay is blocked.
SELECT application_name, write_lag, flush_lag - write_lag AS flush_cost, replay_lag - flush_lag AS replay_cost FROM pg_stat_replication; - 03
Diagnose: ask the standby what it is waiting for
When replay is the slow stage, the standby itself holds the reason. Recovery conflicts and long-running read queries are the usual causes, and they are visible from the standby rather than the primary.
-- run on the standby: SELECT pg_is_in_recovery() AS in_recovery, pg_last_wal_receive_lsn() AS received, pg_last_wal_replay_lsn() AS replayed, now() - pg_last_xact_replay_timestamp() AS behind_by;
Resolution approach
- 1.Record a healthy baseline for all three columns so an incident reading has something to be compared against.
- 2.Identify which stage is growing: arrival, flush, or replay.
- 3.For write_lag, investigate the link and the sender; for flush_lag, the standby's storage; for replay_lag, conflicting queries on the standby.
- 4.Check the standby's own view when replay is the slow stage, including the time since the last replayed transaction.
- 5.Alert on the stage, not the total, so the page names the subsystem to look at.
Stop it recurring
- 01
Watch for streaming stopping entirely
The worst case is not high lag but an absent row: no entry in pg_stat_replication means the standby is not connected at all, and a lag alert built on the column values will never fire.
SELECT count(*) AS connected_standbys FROM pg_stat_replication; - 02
Check the slot is not the constraint
A standby that keeps falling behind and a slot with growing retention are the same story told twice. Read them together.
SELECT slot_name, active, wal_status, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained FROM pg_replication_slots;
Related errors
SQLSTATEs this runbook resolves
The error pages that send an on-call engineer here.
More in this category
Other WAL & replication runbooks
Neighbouring incidents that share the same diagnostic surface.
Connected
How this connects to the rest of the library
A live view of this page's real cross-references — what explains it, what fixes it, what to tune, and where to go next. Every link is an authored relationship, not a guess.