These two parameters answer the same question about the same process: what is the startup process doing, and why is it not done? The startup process is the backend that replays WAL. On a primary it exists only during crash recovery, and while it exists nothing else can connect; a client gets FATAL: the database system is not yet accepting connections. When it finishes it exits and the postmaster opens the doors. On a standby it never finishes; it replays for the life of the server, and the question there is not how far along it is but why it has stopped. One parameter is for each situation, and both answer in the server log, because in both situations the log is the only place you can look.
The primary that went quiet
log_startup_progress_interval arrived in PostgreSQL 15. The default is 10s, the unit is milliseconds if you omit one, 0 disables it, and the context is sighup. Once per interval, while a long startup operation is still running, the startup process writes a line saying so. Three operations qualify: syncing the data directory (an fsync() of every file in the cluster, done whenever the last shutdown was not clean, which includes the first start of a freshly restored base backup), resetting unlogged relations, and WAL replay. Each has its own timer, so a sync that runs 25 seconds gets two messages and an eight-second unlogged reset after it gets none. The replay lines are the ones you will care about, and they carry the LSN:
1 LOG: database system was interrupted; last known up at 2026-09-04 18:16:09 UTC
2 LOG: database system was not properly shut down; automatic recovery in progress
3 LOG: redo starts at 0/902EC1F0
4 LOG: redo in progress, elapsed time: 10.00 s, current LSN: 0/A905D938
5 LOG: redo in progress, elapsed time: 20.00 s, current LSN: 0/B8FB2DB8
6 LOG: redo done at 0/C0B96070 system usage: CPU: user: 11.43 s, system: 8.45 s, elapsed: 27.80 s
Any two of those lines give you a replay rate. The startup process does not know where the WAL ends until it reads an invalid record, so it cannot print an estimate, but the highest-numbered segment in pg_wal is the upper bound, and rate plus distance is an ETA, which is the number the people paging you want. Before 15 you got it by watching the segment name in the startup process’s ps title and timing it yourself, which is Nikolay Samokhvalov’s how-to and still the method on 14. The end-of-recovery checkpoint that follows belongs to the checkpointer, not the startup process, and log_checkpoints, on by default since the same release, reports it.
The commit that added this says why it exists: people start a server, see three lines, see nothing for minutes, and conclude it is hung. What they do next is restart it, which is the worst move available: the restart repeats the data directory sync in full, resumes replay from wherever the last completed restartpoint left it, and adds a HINT to the log that data is probably corrupted and you will need your last backup. That HINT is printed because the control file says the previous startup died mid-recovery, which it did, because you killed it; it is not a diagnosis. The sync phase is the one to watch for. On a large cluster on network storage with a cold cache it can run for many minutes with nothing in the log to show for it, and its progress line names the file it is on. If that is the slow phase, recovery_init_sync_method = syncfs is the fix, and this parameter is how you find out you need it.
On a standby the replay lines are suppressed, since the startup process there would be reporting progress forever; the sync and unlogged-reset phases still report, so a standby that sits silent after redo starts at is replaying, not syncing. You lose little. A standby accepts connections once it reaches consistency, and pg_last_wal_replay_lsn() on the standby and replay_lag in pg_stat_replication on the primary are better instruments than a log line. The one gap is a standby restarting with a large local backlog of WAL: between redo starts at and consistent recovery state reached you get neither SQL nor progress lines, and you are back to ps.
The reload caveat matters more than the context suggests. The timer is armed at the start of each phase with whatever value is in effect at that moment, so if the parameter was 0 when replay began, reloading a new value mid-recovery does nothing; I checked. This is a parameter you set before the crash, and the default is already the right value. The only setting that requires a decision is 0, and the decision is no.
The standby that stopped
log_recovery_conflict_waits arrived in PostgreSQL 14. It is a boolean, off by default, sighup context, and it does nothing anywhere but a hot standby. The mechanism it reports on is the one described under hot_standby: replay and a running query want the same thing (a row version replay must remove, a relation lock replay must take, a buffer pinned by a scan), and the startup process waits up to max_standby_streaming_delay (30 seconds by default, -1 forever, 0 not at all) for the query to get out of the way before cancelling it. With this parameter on, a wait that outlasts deadlock_timeout (the same one-second timer log_lock_waits hangs off, and it is the standby’s own deadlock_timeout that counts) produces one line when it crosses the threshold and one more when it ends:
1 LOG: recovery still waiting after 1077.432 ms: recovery conflict on snapshot
2 DETAIL: Conflicting process: 8617.
3 CONTEXT: WAL redo at 0/7901F4C0 for Heap2/PRUNE_VACUUM_SCAN: snapshotConflictHorizon: 756, ... blkref #0: rel 1663/5/16384, blk 0
4 LOG: recovery finished waiting after 12877.511 ms: recovery conflict on snapshot
That is the complete output. There is nothing between the two lines however long the wait, so a still waiting with no finished waiting after it means the standby is stuck right now. The reason is snapshot, lock, buffer pin, or tablespace (a dropped database does not wait; it throws everyone out immediately). The DETAIL names the standby session in the way, or several of them; buffer pin conflicts have no DETAIL, because PostgreSQL does not track who holds a pin. The CONTEXT is the WAL record itself, in pg_waldump’s format, and rel 1663/5/16384 in it is tablespace, database, and relfilenode: SELECT pg_filenode_relation(1663, 16384) in that database names the table.
The reason to want this is what the example above did not do. That was a thirteen-second replay stall that cancelled nothing. The session finished on its own, so pg_stat_database_conflicts, which counts cancellations, stayed at zero; the query got no error; and the only other evidence was replay_lag climbing on the primary and, had you been looking at that exact moment, the startup process in pg_stat_activity with wait_event = RecoveryConflictSnapshot (Lock/relation for a lock conflict, BufferPin for a pin) and waiting on the end of its process title. Those tell you the standby is stuck now. The log tells you it was stuck at 03:12, for thirteen seconds, on a vacuum record for a particular table, and which session was responsible, which is what you need at 09:00 when someone asks why the reports were stale.
How badly you need it depends on max_standby_streaming_delay. At 0 the startup process never waits, so nothing is ever logged, and nothing needs to be. At the default 30 seconds you get still waiting after one second, the victim’s canceling statement due to conflict with recovery at thirty, and finished waiting immediately after; useful, but mostly reconstructible from the error alone. At -1, the setting of every analytics replica that was ever told to stop cancelling the nightly report, the still waiting line can be the last thing the startup process says for hours, and there is no error to reconstruct from; a standby at -1 falls arbitrarily far behind with nothing to show for it but lag. That configuration is the one this parameter exists for. The reason string also settles whether hot_standby_feedback would have helped, since feedback prevents snapshot conflicts and no other kind.
It costs nothing until the startup process has already been waiting for a second, at which point two more log lines are not your problem. Turn it on on every standby, and leave deadlock_timeout at one second, which is the right threshold here for the same reason it is for lock waits. The startup process is not a talkative process. On a primary it will tell you where it is every ten seconds if you leave the default alone; on a standby it will tell you why it stopped only if you ask.