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.
| Read | Required behavior | Default route |
|---|---|---|
| User reloads a just-saved profile | Read your writes | Primary or session fence |
| Permission check before mutation | Latest authoritative policy | Primary |
| Dashboard aggregate | Bounded staleness | Replica if inside budget |
| Historical export | Stable snapshot, latency tolerant | Dedicated replica |
| Search suggestions | Best effort | Replica 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.
- Primary stickiness: after a successful write, route that user or session to the primary for a bounded interval. It is simple but conservative.
- 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.
- 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.