DEV Community

Libme
Libme

Posted on

hot_standby_feedback for a Reporting Replica: Query Cancellations or Primary Bloat?

For a reporting workload on a Postgres streaming replica, the realistic answer is: keep hot_standby_feedback = on, but only if you also cap the replica's query duration with statement_timeout. Feedback without a timeout hands any analyst an unbounded lever on primary bloat; a timeout without feedback means long queries die with "canceling statement due to conflict with recovery." If reporting queries genuinely need hours, stop sharing the HA replica and give reporting its own replica with max_standby_streaming_delay = -1, accepting that it lags.

This came out of a comment on an earlier post about autovacuum. That post listed hot_standby_feedback = on as one of the things that can pin vacuum's cutoff on the primary, and a reader pointed out, fairly, that it's the one row in that table where the fix has a visible user-facing cost. Turning it off doesn't make the problem go away; it moves the problem from the DBA's bloat graph to the analyst's failed query.

What hot_standby_feedback actually trades

A streaming replica replays WAL from the primary. When the primary vacuums a table, the WAL contains "these row versions are gone." The replica must apply that. If a query on the replica is still reading a snapshot that includes those rows, replay and the query conflict, and one of them has to lose.

With hot_standby_feedback = off (the default), the replica waits up to max_standby_streaming_delay (30 seconds by default), then kills the query:

ERROR:  canceling statement due to conflict with recovery
DETAIL:  User query might have needed to see row versions that must be removed.
HINT:  In a moment you should be able to reconnect to the database and repeat your command.
Enter fullscreen mode Exit fullscreen mode

With hot_standby_feedback = on, the replica sends its oldest running snapshot (backend_xmin) back to the primary. The primary's vacuum then refuses to remove any row version that snapshot could still need. The replica query survives; the primary carries the dead rows until it finishes. If the replica connects through a replication slot, that xmin shows up in pg_replication_slots.xmin on the primary.

That is the whole trade. Feedback converts "replica query cancelled" into "primary table can't be vacuumed for the duration of that query." Neither setting is free; you're choosing who pays.

One thing feedback does not do: it only prevents snapshot conflicts. A DROP TABLE, TRUNCATE, or lock-taking ALTER TABLE on the primary still cancels replica queries touching that relation regardless of feedback, because that's a lock conflict, not a row-removal conflict.

Takeaway: hot_standby_feedback doesn't eliminate a cost, it relocates it from the replica's queries to the primary's vacuum.

Why "just turn feedback on" bit me

What tripped me up was not the setting itself but the missing bound. We enabled feedback on a replica that served both failover and a BI tool. A dashboard query with a bad join plan ran for a little over four hours before anyone noticed. For those four hours, vacuum on the primary's hottest tables removed nothing, the autovacuum log reported success on schedule, and n_dead_tup climbed. When the query finally finished, vacuum caught up, but the bloat it had already accumulated didn't shrink back on its own.

A replica running a four-hour query with feedback on is, from the primary's point of view, indistinguishable from a local connection sitting idle in transaction for four hours. The earlier post's advice about idle_in_transaction_session_timeout and statement_timeout applies here just as strongly, except the timeout has to be set on the replica, where the query actually runs.

Takeaway: with feedback on, the longest query any replica will ever run becomes the longest time the primary can go without effective vacuum.

How do I bound the bloat instead of choosing sides?

Set feedback on and put a hard ceiling on replica query time. Do it per role rather than globally so the HA and ops tooling isn't caught by it:

-- On the replica (or on the primary; role settings replicate)
ALTER ROLE reporting SET statement_timeout = '15min';
ALTER ROLE reporting SET idle_in_transaction_session_timeout = '2min';
Enter fullscreen mode Exit fullscreen mode

Now the worst case on the primary is fifteen minutes of deferred cleanup per reporting query, which autovacuum absorbs without anyone noticing. Fifteen minutes is arbitrary; pick the number that matches the slowest query you're willing to defend.

Then make the two sides observable. On the primary, watch how far the replica's snapshot is holding vacuum back:

SELECT slot_name,
       active,
       age(xmin)         AS xmin_age,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS wal_retained
FROM pg_replication_slots
WHERE slot_type = 'physical';
Enter fullscreen mode Exit fullscreen mode

If xmin_age keeps climbing between samples, something on that replica is holding a snapshot open. Note that a slot keeps its xmin after the standby disconnects; a replica that was shut down mid-query with feedback on can pin the primary until it reconnects or you drop the slot.

On the replica, count how often queries are actually being cancelled, so you're not arguing from anecdotes:

SELECT datname, confl_snapshot, confl_lock, confl_bufferpin, confl_deadlock
FROM pg_stat_database_conflicts
WHERE datname = current_database();
Enter fullscreen mode Exit fullscreen mode

confl_snapshot is the number feedback removes. If it's zero with feedback off, you don't have a problem to solve.

Finally, make the reporting jobs retry. The cancellation is raised with SQLSTATE 40001, the same class as a serialization failure, and the hint in the error text literally says to reconnect and repeat. A scheduled report that treats 40001 as retryable turns most cancellations into a delay rather than a failure.

Takeaway: feedback plus a role-level statement_timeout gives you both outcomes at once: no cancellations for reasonable queries, and a hard ceiling on what any query can cost the primary.

Which replica setup fits which reporting workload?

The options, as of Postgres 16 and 17 (note that vacuum_defer_cleanup_age, the old middle-ground knob, was removed in Postgres 16, so anything recommending it is stale):

Setup Replica queries cancelled? Primary bloat exposure Replica lag Safe as failover target?
feedback off, delay 30s (default) Yes, anything over ~30s during heavy vacuum None Bounded by delay Yes
feedback off, delay raised to minutes Fewer None Grows during long queries Yes, with worse RPO
feedback on, no statement_timeout Rarely (lock conflicts only) Unbounded Minimal Yes
feedback on, statement_timeout per role Only queries over the timeout Bounded by the timeout Minimal Yes
Dedicated reporting replica, feedback off, delay -1 No (replay pauses instead) None Unbounded while queries run No, treat as read-only only
Logical replication or nightly ETL to a separate database No None Hours to a day Not applicable

Raising max_standby_streaming_delay on a shared replica is the option people reach for first, and it's the one I'd argue against. The delay is a global pause on replay. Every long query makes the entire replica stale for every other reader, and if that replica is your failover, you've quietly widened the data you'd lose in a promotion. It fixes the analyst's problem by charging everyone else.

The dedicated replica with max_standby_streaming_delay = -1 is the honest version of that idea. Replay waits as long as it must; nobody's query is cancelled; the primary is untouched. The cost is that the replica can be an hour behind after a big report, so it must never be in the failover pool, and anyone reading from it needs to know pg_last_xact_replay_timestamp() before trusting a number. For workloads with multi-hour queries, this is the setup that actually works.

Takeaway: the middle knob, max_standby_streaming_delay, only behaves well on a replica that has no other job.

FAQ

Should I turn on hot_standby_feedback for a read replica?
Yes, if you also set a statement_timeout on the roles that query the replica. Feedback with no timeout lets a single slow query block vacuum on the primary indefinitely.

Why does my replica query fail with "canceling statement due to conflict with recovery"?
The primary vacuumed rows your query's snapshot still needed, and the replica waited max_standby_streaming_delay (30 seconds by default) before cancelling you so replay could continue. Enable hot_standby_feedback on the replica, or move long queries to a replica where replay is allowed to pause.

Does hot_standby_feedback increase replication lag?
No. It reduces conflicts, which reduces the time replay spends waiting, so lag typically drops. The cost is on the primary side, as deferred vacuum cleanup, not on the replica.

Bottom line

If one replica serves both HA and reporting, run hot_standby_feedback = on with a role-level statement_timeout that reflects the longest query you're willing to defend, and alert on pg_replication_slots xmin age from the primary. If reporting needs multi-hour queries, split it out: a dedicated replica with feedback off and max_standby_streaming_delay = -1, excluded from failover, with readers checking replay timestamp. Raising the streaming delay on a shared replica is the one option that makes everyone's situation slightly worse to make one team's slightly better.

Related reading

Top comments (0)