{"article":{"slug":"postgres-recovery-prefetch-considered-harmful-and-causing-outages","title":"Postgres recovery_prefetch Considered Harmful and Causing Outages","subtitle":null,"summary":"Jeremy Schneider describes a field investigation showing that Postgres's recovery_prefetch setting can quietly cause standby replay lag and production outages, tracing it back to a concern raised on the hackers list years ago and explaining what he found.","content_type":"blog_post","language":"en","canonical_url":"https://ardentperf.com/2026/10/20/postgres-recovery_prefetch-considered-harmful-and-causing-outages/","author":{"name":"Jeremy Schneider","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Ardent Performance Computing","url":"https://ardentperf.com/","listing_slug":null,"listing":null},"topics":[{"name":"Databases","slug":"databases","url":"https://listedarticles.com/topics/databases"},{"name":"Performance","slug":"performance","url":"https://listedarticles.com/topics/performance"},{"name":"Infrastructure","slug":"infrastructure","url":"https://listedarticles.com/topics/infrastructure"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":1253,"reading_minutes":5,"published_at":"2026-10-21T06:59:12.000Z","added_at":"2026-10-22T05:07:13.441Z","updated_at":"2026-10-22T05:07:13.441Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/postgres-recovery-prefetch-considered-harmful-and-causing-outages","markdown_url":"https://listedarticles.com/articles/postgres-recovery-prefetch-considered-harmful-and-causing-outages.md","example":false,"citation":"Jeremy Schneider, Ardent Performance Computing. \"Postgres recovery_prefetch Considered Harmful and Causing Outages.\" 21 Oct 2026. https://ardentperf.com/2026/10/20/postgres-recovery_prefetch-considered-harmful-and-causing-outages/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://ardentperf.com/2026/10/20/postgres-recovery_prefetch-considered-harmful-and-causing-outages/"},"body_markdown":"Another story of something I saw in the field which surprised me. Turns out `recovery_prefetch` can cause outages.\n\nSomething which has been happening quietly in the background all the time, but we didn’t notice until we looked close enough to see it.\n\nAfter a lot of work I discovered this was partly a case where someone had dropped an email to the postgres hackers list years ago “hey what about this” and everyone thought “nah it can’t be that bad” … and now here we are seeing production outages!\n\n**Exhibit A: Feb 2021, while the feature was being developed, a year and a half before it shipped, Stephen Frost**\n\n- *Issuing unnecessary prefetches for blocks we’ve already sent a prefetch for is arguably a concern even if FPWs are off but the benefit of doing the prefetching almost certainly will outweight that and mean that finding a way to address it is something we could certainly do later as a future improvement. I wouldn’t have any issue with that. **Just doesn’t seem as clear-cut to me when thinking about the FPW-enabled case.** Ultimately, if you, Andres and Munro are all not concerned about it and no one else speaks up then I’m not going to pitch a fuss over it being committed, but, as you said above, it seemed like a good point to raise for everyone to consider.*\n\n<https://www.postgresql.org/message-id/20210214231815.GO27507@tamriel.snowman.net>\n\n**Exhibit B: Mar 2025, clear report from the field, Michael Zhilin (Postgres Pro)**\n\n- *One customer complained that physical standby has significant replay lag (few hours) on modern powerful bare metal servers. According to output of “top”, startup process and few kernel workers utilized CPU, mostly in kernel space. **The incident has been resolved by disabling recovery\\_prefetch** after quick analysis of perf and strace outputs. Actual XLog prefetching code doesn’t consider that shared buffer may be loaded from WAL FPI rather than from disk and makes fadvise calls in such cases.*\n\n<https://www.postgresql.org/message-id/9bdb428a-fc8e-4801-8fff-1ad0bc6fa76b%40postgrespro.ru>\n\nBoth of these emails were on the right track, but didn’t quite get the complete picture yet.\n\nFirst: FPI/FPW is ubiquitous today. While it’s very expensive in terms of performance, it’s the only way Postgres has of protecting from torn writes on OS restarts. As a result, the majority of production Postgres installations today (community oss version) enable this.\n\nSecond: synchronous replication is becoming more common. There are really only two ways to completely protect community open source postgres against data loss on hardware failures: (1) storage layer replication with oss tech like DRBD or expensive proprietary storage & clustering technology or (2) database layer protection with postgres synchronous replication. The second option is the normal solution with kubernetes and commodity hardware.\n\nThird: something that hasn’t previously been diagnosed/reported – the problem with `recovery_prefetch` isn’t just about FPIs, it’s also about the fact that when the primary instance does large sequential I/O, `recovery_prefetch` converts those operations into 8k random reads. For the same number of bytes read, this amplifies IOPS as much as 16x on replicas. This can easily exhaust available IOPS, causing replicas to significantly lag after write activity.\n\nThe read IOPS amplification **combined with Postgres design for synchronous replication** is what turns `recovery_prefetch` into an actual outage-inducing killer. And it turns out this is trivial to reproduce once you know the steps.\n\n1. Start up postgres with synchronous replication to a standby, on small servers with inexpensive hard drives (for example: cloud disk with **500 IOPS, 2 CPUs, 8GB memory**)\n2. Initialize pgbench scale 640 for about 10GB data\n3. Disable autovac on pgbench\\_accounts just so it doesn’t interfere with the experiment.\n4. Run pgbench at 5 TPS fixed in a terminal you can watch.\n5. **Update 5% of the table**. (Checkpoint and update pgbench\\_accounts where aid%20 = 0.) This generates a burst of write activity. Pgbench will slow down until it finishes. Wait for this to complete.\n6. Kill the walreceiver process to **emulate network/system hang or instability** that disrupts this connection.\n7. **TPS drops to zero and all writes hang for over 30 minutes** (can be hours with larger pgbench table).\n\n[![](https://ardentperf.com/wp-content/uploads/2026/10/image-1.png?w=1024)](https://ardentperf.com/wp-content/uploads/2026/10/image-1.png)\n\nIt really is this easy to consistently reproduce this outage. <https://github.com/ardentperf-labs/walreceiver-outage-repro>\n\nAnd for what it’s worth, VACUUM itself is quite capable of producing write activity too. The recipe for an outage is simply “enough bad luck”: a network glitch that interrupts streaming replication at the same time as a burst of write activity.\n\nTo understand why the whole system hangs, it really comes down to two simple things: (1) synchronous replication and (2) the core “state machine” that defines a Postgres Standby Server.\n\nRemember that with synchronous replication, we’re depending on at least one healthy standby server – otherwise we can’t take writes. (We want to guarantee durability to clients and ensure every transaction is on a remote machine before commit.)\n\nThe [core standby server state machine is defined](https://github.com/postgres/postgres/blob/REL_18_STABLE/src/backend/access/transam/xlogrecovery.c#L3593-L4035) in `src/backend/access/transam/xlogrecovery.c`:\n\n```\nWaitForWALToBecomeAvailable(XLogRecPtr RecPtr, bool randAccess,\n\t\t\t\t\t\t\tbool fetching_ckpt, XLogRecPtr tliRecPtr,\n\t\t\t\t\t\t\tTimeLineID replayTLI, XLogRecPtr replayLSN,\n\t\t\t\t\t\t\tbool nonblocking)\n{\n\tstatic TimestampTz last_fail_time = 0;\n\tTimestampTz now;\n\tbool\t\tstreaming_reply_sent = false;\n\n\t/*-------\n\t * Standby mode is implemented by a state machine:\n\t *\n\t * 1. Read from either archive or pg_wal (XLOG_FROM_ARCHIVE), or just\n\t *\t  pg_wal (XLOG_FROM_PG_WAL)\n\t * 2. Check for promotion trigger request\n\t * 3. Read from primary server via walreceiver (XLOG_FROM_STREAM)\n\t * 4. Rescan timelines\n\t * 5. Sleep wal_retrieve_retry_interval milliseconds, and loop back to 1.\n\t *\n\t * Failure to read from the current source advances the state machine to\n\t * the next state.\n...\n...\n```\n\nFundamentally it looks like this:\n\n[![](https://ardentperf.com/wp-content/uploads/2026/10/image-4.png?w=1024)](https://ardentperf.com/wp-content/uploads/2026/10/image-4.png)\n\nBasically, when a Postgres standby switches into WAL-restore mode, it will not go back to streaming until it’s caught up.\n\nA burst of write activity combined with IOPS amplification on all the replicas that causes significant lag can easily mean the database **won’t start streaming again for hours**.\n\nThe IOPS amplification is very clear. Here are the disk stats for the primary (maybe 5 minutes around 300 IOPS):\n\n[![](https://ardentperf.com/wp-content/uploads/2026/10/image-6.png?w=1024)](https://ardentperf.com/wp-content/uploads/2026/10/image-6.png)\n\nHere are the disk status for the replica/standby during the same time period (40 minutes at the disk limit of 500 IOPS):\n\n[![](https://ardentperf.com/wp-content/uploads/2026/10/image-7.png?w=1024)](https://ardentperf.com/wp-content/uploads/2026/10/image-7.png)\n\nThese charts don’t illustrate it, but the number of bytes read from disk is about the same.\n\nWhat happens if we disable `recovery_prefetch`? (Note that FPWs remain enabled.)\n\n<https://github.com/ardentperf-labs/walreceiver-outage-repro/blob/main/variations/README.md#recovery_prefetchoff>\n\n[![](https://ardentperf.com/wp-content/uploads/2026/10/image-11.png?w=1024)](https://ardentperf.com/wp-content/uploads/2026/10/image-11.png)\n\nThere’s still the pause during UPDATE but we didn’t see the long pause after UPDATE completes.\n\n[![](https://ardentperf.com/wp-content/uploads/2026/10/image-8.png?w=1024)](https://ardentperf.com/wp-content/uploads/2026/10/image-8.png)\n\n[![](https://ardentperf.com/wp-content/uploads/2026/10/image-10.png?w=1024)](https://ardentperf.com/wp-content/uploads/2026/10/image-10.png)\n\nThere is no burst of read IOPS, and the standby server basically stays caught up – applying the 5% update in less than 10 minutes.\n\nRight now, I’m leaning toward disabling the `recovery_prefetch` setting across the board but I want to see where the discussions go. I think its benefits are a little dubious with FPWs enabled by default.\n\n## Appendix: Postgres Standby Server Replay Process\n\nHere’s a more detailed refresher on the various processes involved in the core restore and replay logic of a postgres standby server.\n\nImportantly, note the direction of the arrow between the WAL sender and WAL receiver. With synchronous replication in Postgres this connection is critical – the primary can’t accept writes without it. It’s also very fragile, because if the connection disappears the primary can only sit and wait, blocking all writes, hoping a standby will reconnect soon!\n\nThis is a reason to consider two standbys and quorum commit (ANY 1) with postgres. While it does increase cost to run with three total replicas instead of two, it significantly increases the robustness of the database.\n\n[![](https://ardentperf.com/wp-content/uploads/2026/10/image-14.png?w=993)](https://ardentperf.com/wp-content/uploads/2026/10/image-14.png)\n","body_html":"<p>Another story of something I saw in the field which surprised me. Turns out <code>recovery_prefetch</code> can cause outages.</p>\n<p>Something which has been happening quietly in the background all the time, but we didn’t notice until we looked close enough to see it.</p>\n<p>After a lot of work I discovered this was partly a case where someone had dropped an email to the postgres hackers list years ago “hey what about this” and everyone thought “nah it can’t be that bad” … and now here we are seeing production outages!</p>\n<p><strong>Exhibit A: Feb 2021, while the feature was being developed, a year and a half before it shipped, Stephen Frost</strong></p>\n<ul><li><em>Issuing unnecessary prefetches for blocks we’ve already sent a prefetch for is arguably a concern even if FPWs are off but the benefit of doing the prefetching almost certainly will outweight that and mean that finding a way to address it is something we could certainly do later as a future improvement. I wouldn’t have any issue with that. <strong>Just doesn’t seem as clear-cut to me when thinking about the FPW-enabled case.</strong> Ultimately, if you, Andres and Munro are all not concerned about it and no one else speaks up then I’m not going to pitch a fuss over it being committed, but, as you said above, it seemed like a good point to raise for everyone to consider.</em></li></ul>\n<p><a href=\"https://www.postgresql.org/message-id/20210214231815.GO27507@tamriel.snowman.net\" rel=\"nofollow ugc noopener\">https://www.postgresql.org/message-id/20210214231815.GO27507@tamriel.snowman.net</a></p>\n<p><strong>Exhibit B: Mar 2025, clear report from the field, Michael Zhilin (Postgres Pro)</strong></p>\n<ul><li><em>One customer complained that physical standby has significant replay lag (few hours) on modern powerful bare metal servers. According to output of “top”, startup process and few kernel workers utilized CPU, mostly in kernel space. <strong>The incident has been resolved by disabling recovery_prefetch</strong> after quick analysis of perf and strace outputs. Actual XLog prefetching code doesn’t consider that shared buffer may be loaded from WAL FPI rather than from disk and makes fadvise calls in such cases.</em></li></ul>\n<p><a href=\"https://www.postgresql.org/message-id/9bdb428a-fc8e-4801-8fff-1ad0bc6fa76b%40postgrespro.ru\" rel=\"nofollow ugc noopener\">https://www.postgresql.org/message-id/9bdb428a-fc8e-4801-8fff-1ad0bc6fa76b%40postgrespro.ru</a></p>\n<p>Both of these emails were on the right track, but didn’t quite get the complete picture yet.</p>\n<p>First: FPI/FPW is ubiquitous today. While it’s very expensive in terms of performance, it’s the only way Postgres has of protecting from torn writes on OS restarts. As a result, the majority of production Postgres installations today (community oss version) enable this.</p>\n<p>Second: synchronous replication is becoming more common. There are really only two ways to completely protect community open source postgres against data loss on hardware failures: (1) storage layer replication with oss tech like DRBD or expensive proprietary storage &amp; clustering technology or (2) database layer protection with postgres synchronous replication. The second option is the normal solution with kubernetes and commodity hardware.</p>\n<p>Third: something that hasn’t previously been diagnosed/reported – the problem with <code>recovery_prefetch</code> isn’t just about FPIs, it’s also about the fact that when the primary instance does large sequential I/O, <code>recovery_prefetch</code> converts those operations into 8k random reads. For the same number of bytes read, this amplifies IOPS as much as 16x on replicas. This can easily exhaust available IOPS, causing replicas to significantly lag after write activity.</p>\n<p>The read IOPS amplification <strong>combined with Postgres design for synchronous replication</strong> is what turns <code>recovery_prefetch</code> into an actual outage-inducing killer. And it turns out this is trivial to reproduce once you know the steps.</p>\n<ol><li>Start up postgres with synchronous replication to a standby, on small servers with inexpensive hard drives (for example: cloud disk with <strong>500 IOPS, 2 CPUs, 8GB memory</strong>)</li><li>Initialize pgbench scale 640 for about 10GB data</li><li>Disable autovac on pgbench_accounts just so it doesn’t interfere with the experiment.</li><li>Run pgbench at 5 TPS fixed in a terminal you can watch.</li><li><strong>Update 5% of the table</strong>. (Checkpoint and update pgbench_accounts where aid%20 = 0.) This generates a burst of write activity. Pgbench will slow down until it finishes. Wait for this to complete.</li><li>Kill the walreceiver process to <strong>emulate network/system hang or instability</strong> that disrupts this connection.</li><li><strong>TPS drops to zero and all writes hang for over 30 minutes</strong> (can be hours with larger pgbench table).</li></ol>\n<p><a href=\"https://ardentperf.com/wp-content/uploads/2026/10/image-1.png\" rel=\"nofollow ugc noopener\"><img src=\"https://ardentperf.com/wp-content/uploads/2026/10/image-1.png?w=1024\" alt=\"\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></a></p>\n<p>It really is this easy to consistently reproduce this outage. <a href=\"https://github.com/ardentperf-labs/walreceiver-outage-repro\" rel=\"nofollow ugc noopener\">https://github.com/ardentperf-labs/walreceiver-outage-repro</a></p>\n<p>And for what it’s worth, VACUUM itself is quite capable of producing write activity too. The recipe for an outage is simply “enough bad luck”: a network glitch that interrupts streaming replication at the same time as a burst of write activity.</p>\n<p>To understand why the whole system hangs, it really comes down to two simple things: (1) synchronous replication and (2) the core “state machine” that defines a Postgres Standby Server.</p>\n<p>Remember that with synchronous replication, we’re depending on at least one healthy standby server – otherwise we can’t take writes. (We want to guarantee durability to clients and ensure every transaction is on a remote machine before commit.)</p>\n<p>The <a href=\"https://github.com/postgres/postgres/blob/REL_18_STABLE/src/backend/access/transam/xlogrecovery.c#L3593-L4035\" rel=\"nofollow ugc noopener\">core standby server state machine is defined</a> in <code>src/backend/access/transam/xlogrecovery.c</code>:</p>\n<pre><code>WaitForWALToBecomeAvailable(XLogRecPtr RecPtr, bool randAccess,\n                            bool fetching_ckpt, XLogRecPtr tliRecPtr,\n                            TimeLineID replayTLI, XLogRecPtr replayLSN,\n                            bool nonblocking)\n{\n    static TimestampTz last_fail_time = 0;\n    TimestampTz now;\n    bool        streaming_reply_sent = false;\n\n    /*-------\n     * Standby mode is implemented by a state machine:\n     *\n     * 1. Read from either archive or pg_wal (XLOG_FROM_ARCHIVE), or just\n     *      pg_wal (XLOG_FROM_PG_WAL)\n     * 2. Check for promotion trigger request\n     * 3. Read from primary server via walreceiver (XLOG_FROM_STREAM)\n     * 4. Rescan timelines\n     * 5. Sleep wal_retrieve_retry_interval milliseconds, and loop back to 1.\n     *\n     * Failure to read from the current source advances the state machine to\n     * the next state.\n...\n...</code></pre>\n<p>Fundamentally it looks like this:</p>\n<p><a href=\"https://ardentperf.com/wp-content/uploads/2026/10/image-4.png\" rel=\"nofollow ugc noopener\"><img src=\"https://ardentperf.com/wp-content/uploads/2026/10/image-4.png?w=1024\" alt=\"\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></a></p>\n<p>Basically, when a Postgres standby switches into WAL-restore mode, it will not go back to streaming until it’s caught up.</p>\n<p>A burst of write activity combined with IOPS amplification on all the replicas that causes significant lag can easily mean the database <strong>won’t start streaming again for hours</strong>.</p>\n<p>The IOPS amplification is very clear. Here are the disk stats for the primary (maybe 5 minutes around 300 IOPS):</p>\n<p><a href=\"https://ardentperf.com/wp-content/uploads/2026/10/image-6.png\" rel=\"nofollow ugc noopener\"><img src=\"https://ardentperf.com/wp-content/uploads/2026/10/image-6.png?w=1024\" alt=\"\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></a></p>\n<p>Here are the disk status for the replica/standby during the same time period (40 minutes at the disk limit of 500 IOPS):</p>\n<p><a href=\"https://ardentperf.com/wp-content/uploads/2026/10/image-7.png\" rel=\"nofollow ugc noopener\"><img src=\"https://ardentperf.com/wp-content/uploads/2026/10/image-7.png?w=1024\" alt=\"\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></a></p>\n<p>These charts don’t illustrate it, but the number of bytes read from disk is about the same.</p>\n<p>What happens if we disable <code>recovery_prefetch</code>? (Note that FPWs remain enabled.)</p>\n<p><a href=\"https://github.com/ardentperf-labs/walreceiver-outage-repro/blob/main/variations/README.md#recovery_prefetchoff\" rel=\"nofollow ugc noopener\">https://github.com/ardentperf-labs/walreceiver-outage-repro/blob/main/variations/README.md#recovery_prefetchoff</a></p>\n<p><a href=\"https://ardentperf.com/wp-content/uploads/2026/10/image-11.png\" rel=\"nofollow ugc noopener\"><img src=\"https://ardentperf.com/wp-content/uploads/2026/10/image-11.png?w=1024\" alt=\"\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></a></p>\n<p>There’s still the pause during UPDATE but we didn’t see the long pause after UPDATE completes.</p>\n<p><a href=\"https://ardentperf.com/wp-content/uploads/2026/10/image-8.png\" rel=\"nofollow ugc noopener\"><img src=\"https://ardentperf.com/wp-content/uploads/2026/10/image-8.png?w=1024\" alt=\"\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></a></p>\n<p><a href=\"https://ardentperf.com/wp-content/uploads/2026/10/image-10.png\" rel=\"nofollow ugc noopener\"><img src=\"https://ardentperf.com/wp-content/uploads/2026/10/image-10.png?w=1024\" alt=\"\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></a></p>\n<p>There is no burst of read IOPS, and the standby server basically stays caught up – applying the 5% update in less than 10 minutes.</p>\n<p>Right now, I’m leaning toward disabling the <code>recovery_prefetch</code> setting across the board but I want to see where the discussions go. I think its benefits are a little dubious with FPWs enabled by default.</p>\n<h2 id=\"appendix-postgres-standby-server-replay-process\">Appendix: Postgres Standby Server Replay Process</h2>\n<p>Here’s a more detailed refresher on the various processes involved in the core restore and replay logic of a postgres standby server.</p>\n<p>Importantly, note the direction of the arrow between the WAL sender and WAL receiver. With synchronous replication in Postgres this connection is critical – the primary can’t accept writes without it. It’s also very fragile, because if the connection disappears the primary can only sit and wait, blocking all writes, hoping a standby will reconnect soon!</p>\n<p>This is a reason to consider two standbys and quorum commit (ANY 1) with postgres. While it does increase cost to run with three total replicas instead of two, it significantly increases the robustness of the database.</p>\n<p><a href=\"https://ardentperf.com/wp-content/uploads/2026/10/image-14.png\" rel=\"nofollow ugc noopener\"><img src=\"https://ardentperf.com/wp-content/uploads/2026/10/image-14.png?w=993\" alt=\"\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></a></p>","headings":[{"level":2,"text":"Appendix: Postgres Standby Server Replay Process","id":"appendix-postgres-standby-server-replay-process"}]}}