A Read Replica Needs a Freshness Contract

Sep 5

Adding a PostgreSQL read replica looks like a capacity decision: send reads elsewhere and give the primary room to breathe. In production it is a semantics decision. Asynchronous replication permits a committed write to be temporarily absent from the replica. A router that ignores that fact silently changes what the product means.

A replica is safe only for reads whose tolerated staleness is explicit.

“Eventually consistent” is not a usable contract. The useful questions are: how stale, for which operation, and what happens when the bound is exceeded?

Classify reads before routing them

Use product behavior, not SQL verbs, to classify a read.

ReadRequired behaviorDefault route
User reloads a just-saved profileRead your writesPrimary or session fence
Permission check before mutationLatest authoritative policyPrimary
Dashboard aggregateBounded stalenessReplica if inside budget
Historical exportStable snapshot, latency tolerantDedicated replica
Search suggestionsBest effortReplica with degradation

The same SELECT can belong to different classes. A balance shown before authorizing a payment is not equivalent to the balance displayed in a weekly report.

Measure replay position, not replica health

A green TCP check proves that the server accepts connections. It does not prove that replay is current. Track both time and byte distance where possible: last WAL receive position, last replay position, replay timestamp, and primary WAL position.

Time lag alone can mislead during a quiet period because there may be no new commit timestamp to compare. Byte lag alone does not translate directly to user-visible time. Together they explain more.

SELECT
  pg_is_in_recovery() AS is_replica,
  pg_last_wal_receive_lsn() AS received,
  pg_last_wal_replay_lsn() AS replayed,
  now() - pg_last_xact_replay_timestamp() AS replay_time_lag;

Treat this as an operational probe, not a universal routing query. Cache the result briefly in the router and alert on the underlying series.

Preserve read your writes deliberately

There are three practical patterns.

  1. Primary stickiness: after a successful write, route that user or session to the primary for a bounded interval. It is simple but conservative.
  2. Commit-position fence: return a commit/WAL position and permit replica reads only after replay reaches it. This is precise but couples the application to database progress semantics.
  3. Version-aware response: include a domain version in writes and refuse to present an older object version. This works across more storage topologies but requires product-level reconciliation.

Do not use a fixed 500 ms sleep. Replication delay is workload- and incident-dependent; sleeping converts uncertainty into latency without establishing correctness.

Long reads compete with recovery

Hot standby queries can conflict with WAL replay. PostgreSQL may wait and then cancel a conflicting query according to max_standby_streaming_delay. The setting is a cumulative allowance for applying received WAL, not a per-query execution timeout. One query can consume most of the allowance available to later queries.

Increasing the delay protects analytics queries but permits replay lag to grow. Enabling hot_standby_feedback can reduce cleanup conflicts, while allowing dead row versions to remain longer on the primary and contribute to bloat. Neither knob is free.

Separate latency-sensitive replica traffic from unbounded analytical work. Apply statement timeouts and workload-specific connection pools. A reporting query should not consume the freshness budget of customer-facing reads.

Define overload and failure behavior

Automatic fallback to the primary can turn a replica incident into a primary incident. Decide ahead of time:

  • critical correctness reads may fall back within a protected primary capacity budget;
  • stale-tolerant views may serve a marked stale result;
  • optional panels may disappear;
  • batch work may pause;
  • no class may create unbounded primary failover traffic.

Protect the fallback path with admission control and a separate pool. The rate-limiting guide explains why rejecting work can preserve the critical path.

Test the contract

Continuously exercise a write followed by reads through the real router. Inject replay delay, disconnect the replica, run a conflicting long query, and exhaust the fallback pool. Verify user-visible behavior and alarms—not just infrastructure metrics.

The architecture review should end with four numbers: staleness budget by read class, fallback capacity, query deadline, and maximum analytical concurrency. Without them, “reads go to replicas” is not an architecture. It is an unmeasured correctness change.

References

>