hot_standby_feedback does not eliminate a problem. It moves one, from the standby to the primary, and whether that is a good trade is the entire question.

The problem is recovery conflicts that cancel long-running queries on a standby. The standby is replaying the primary’s changes while also answering read queries, and when the primary vacuums away dead row versions, replaying that cleanup on the standby collides with any query still relying on those versions. The standby resolves the collision by killing the query, and the user sees ERROR: canceling statement due to conflict with recovery. hot_standby_feedback stops this by asking the primary not to remove those rows in the first place. The cost lands on the primary, as bloat, because rows it would otherwise reclaim are now pinned by a query running on another machine.

That is why it is off by default. The default posture is that a standby’s queries should not be able to reach back and constrain the primary’s maintenance. Turning it on is a deliberate decision to let them.

The mechanism

It is a boolean, its default is off, and its context is sighup, set in postgresql.conf or on the command line, on the standby. The feature arrived in PostgreSQL 9.1, a release after Hot Standby itself, precisely to address the query-cancellation problem that 9.0 had shipped with.

With it on, the standby works out the oldest transaction ID any of its running queries still needs, and reports that value upstream, no more often than once per wal_receiver_status_interval, which defaults to ten seconds. The primary folds that reported xmin into its own calculation of what is safe to remove, and holds vacuum back so it never cleans up a row version newer than what the standby says it needs. In a cascade, the feedback is relayed from standby to standby until it reaches the primary; intermediate standbys do nothing with it except pass it along.

The cost, stated fairly

The cost is bloat on the primary. Vacuum is being held back, so dead tuples accumulate in tables and indexes for as long as the standby’s oldest query runs. A single forgotten transaction on the standby, one BEGIN someone walked away from, can stall cleanup on the primary indefinitely and bloat it badly, which is the failure mode to respect.

The documentation is careful to put a fair bound on this, though, and it is worth repeating because it corrects the instinct to treat feedback as uniquely dangerous. The bloat is no worse than you would get by running the same query directly on the primary. Feedback does not invent a new category of risk; it gives a long query on the standby exactly the vacuum-blocking effect the identical query would have if it ran on the primary, while you still get the benefit of that query’s load being somewhere other than the primary. It is the same cost you already understood, relocated.

What it does not do

Here is the misunderstanding that generates support tickets. hot_standby_feedback = on does not prevent recovery conflicts. It prevents one kind of recovery conflict, the kind caused by row cleanup, and leaves every other kind untouched.

A query on the standby can still be canceled by an AccessExclusiveLock replicated from the primary when someone runs certain DDL or a TRUNCATE there, by the replay of a dropped database or tablespace, or by a buffer pin conflict. None of those are cleanup conflicts, so feedback has no effect on them. People enable it, watch their queries continue to get canceled, and conclude it is broken or misconfigured. It is neither. It was only ever pointed at the cleanup case, which happens to be the most common one, not the only one.

It is also not durable

Feedback exists only while the standby is connected and current. If the standby disconnects, the primary stops hearing the xmin and resumes vacuuming on its normal schedule, so cleanup that conflicts with the standby’s queries can accumulate during the gap and bite when the standby reconnects and replays it. The documentation suggests raising max_standby_archive_delay to cushion exactly that reconnection window. Even a connected standby has narrow races, a walreceiver network hiccup, or a cleanup record that beats the latest xmin update to the primary, in which a cleanup conflict slips through anyway. Treat feedback as a strong mitigation, not a guarantee.

The other lever, and the one that’s gone

Feedback trades primary bloat. The alternative is max_standby_streaming_delay, which trades standby lag instead: it tells the standby to wait, up to some bound, before canceling a conflicting query, letting replay fall behind rather than killing the query or bloating the primary. Which lever you want follows from one question. If the standby must stay current, use feedback and accept the bloat. If the standby is allowed to lag, a generous delay may serve you better with no cost to the primary at all, and you can leave feedback off.

There used to be a third lever, vacuum_defer_cleanup_age, which deferred cleanup on the primary by a fixed number of transactions. It was always awkward, because a transaction count is nearly impossible to translate into a useful amount of grace time, and PostgreSQL 16 removed it outright as redundant now that feedback and replication slots exist. If you find it in an old configuration, it is a fossil.

Running it safely

If you turn it on, the job is to bound the blast radius so one runaway query cannot bloat the primary without limit. Watch the age of the reported xmin, visible as backend_xmin in pg_stat_replication on the primary, and alarm when it grows. Watch pg_stat_activity on the standby for queries measured in hours. And set a statement_timeout on the standby so a forgotten query is eventually killed on its own rather than allowed to hold the primary’s cleanup horizon open forever. On the standby, pg_stat_database_conflicts shows how many queries have been canceled and why, which is how you tell whether feedback is even addressing the conflicts you are actually hitting.

The summary

Leave hot_standby_feedback off unless you are running read replicas whose long queries cannot be allowed to cancel and which must stay current with the primary. In that case turn it on, and turn it on knowing three things: you have traded standby query cancellations for primary bloat, the trade covers only cleanup conflicts and not the lock and drop conflicts that will still cancel queries, and it protects nothing while the standby is disconnected. Above all, cap how long a query on the standby can run, because the whole cost of this parameter is measured by the duration of the longest query holding it open.