Read-Your-Writes Consistency with Read Replicas
Problem statement: A user updates their profile, the write commits on the primary, the next page load reads from a replica that has not yet applied that change, and the user sees their old data — the classic read-your-writes violation that appears the instant you start routing reads off the primary.
Symptom Identification
This class of bug is easy to reproduce and maddening to catch in code review, because the code is correct in isolation — it only breaks under the timing of asynchronous replication. Watch for these signals:
- “I saved it but it didn’t save” support tickets that the user cannot reproduce a minute later. The write did land; the immediate read hit a lagging replica.
- A create-then-redirect flow where the destination page 404s or shows an empty list, but a manual refresh a second later renders correctly.
- Toggling a setting that reverts on the next request, then sticks on the one after that.
- Integration tests that pass against a single database but fail intermittently in staging where a real replica sits behind the primary.
- Metrics showing a read landing on a replica whose
replay_lsnis behind the LSN your own request just committed.
The common thread is that the stale window is exactly one replication-lag interval wide and always follows the reader’s own write. If a hard refresh fixes it, you are almost certainly looking at a read-your-writes gap rather than a caching or application-state bug.
Root Cause Analysis
Under asynchronous replication, the primary commits and acknowledges a write before the replica has received or applied the corresponding WAL. A read dispatched to that replica in the intervening window sees a snapshot that predates the write. Nothing is broken — this is the defining trade-off of read scaling, and the reason evaluating consistency models for distributed reads matters before you split traffic.
Read-your-writes (also called read-after-write) is a session-level guarantee: within one session, any read reflects at least that session’s own prior writes. It is strictly weaker than global strong consistency and strictly stronger than eventual consistency. The key insight is that you do not need every replica to be current — you only need the replica serving this read to have applied this session’s last write. That reframes an unbounded problem (“keep replicas fresh”) into a bounded one (“compare one position against one threshold”).
The mechanism that makes it tractable is that both PostgreSQL and MySQL expose a monotonic commit coordinate. PostgreSQL uses the WAL Log Sequence Number (LSN); MySQL uses the Global Transaction Identifier (GTID) set. Capture that coordinate at write time, carry it in the session, and gate replica reads on it. When no replica qualifies in time, fall back to the primary — which is always current by definition.
Architecture: The Token Round-Trip
The sequence below shows the full round-trip: the write returns a commit position, the session carries it, and the read path either finds a caught-up replica or falls back to the primary.
Step-by-Step Resolution
Step 1: Capture the commit position at write time
Read the commit coordinate in the same round-trip as the write, so you never miss the position of a transaction you just committed.
-- PostgreSQL: capture the WAL position right after commit
-- Run this on the SAME primary connection that performed the write.
SELECT pg_current_wal_lsn() AS commit_lsn;-- MySQL: capture the GTID set that now includes your transaction
SELECT @@GLOBAL.gtid_executed AS committed_gtids;For PostgreSQL, pg_current_wal_lsn() returns the current write position on the primary, which is at or past the LSN of the transaction you just committed. For MySQL with GTIDs enabled (gtid_mode = ON, enforce_gtid_consistency = ON), @@GLOBAL.gtid_executed is the authoritative set of applied transactions.
Inline verification: The returned value must be non-empty and must increase across successive writes. On PostgreSQL, SELECT pg_wal_lsn_diff('0/3A2F1B8', '0/3A2F000') > 0 confirms ordering; on MySQL the GTID set should include your server’s UUID with a rising interval.
Step 2: Persist the token in session-scoped storage
Store the highest LSN or GTID set the session has produced. In-process, a request-scoped context variable is enough; across devices you need a shared store (see the edge cases below).
# Request-scoped token, advanced on every write (Python / async-safe)
from contextvars import ContextVar
_write_token: ContextVar[str | None] = ContextVar("write_token", default=None)
def record_commit(lsn: str) -> None:
current = _write_token.get()
# Monotonic: only ever move the watermark forward
if current is None or lsn_gt(lsn, current):
_write_token.set(lsn)Inline verification: After a write, _write_token.get() returns the LSN from Step 1. After a second write it returns the larger of the two — never the smaller.
Step 3: Gate replica selection on the token
Before dispatching a read, ask each candidate replica for its current replay position and route only to a replica that has caught up to the token.
-- PostgreSQL: current apply position on the replica
SELECT pg_last_wal_replay_lsn() AS replay_lsn;
-- Route here only if replay_lsn >= session tokendef pick_replica(token: str, replicas) -> "Conn | None":
for r in replicas:
replay = r.execute("SELECT pg_last_wal_replay_lsn()").scalar()
if lsn_ge(replay, token):
return r # this replica has applied the session's write
return None # none caught up — caller falls back to primaryInline verification: Immediately after a write, pick_replica may return None (expected during the lag window). A moment later the same call returns a replica whose replay_lsn is at or past the token.
Step 4: Wait for catch-up, then fall back to the primary
Rather than reject the read when no replica qualifies, give the replica a bounded chance to catch up. MySQL has a purpose-built blocking function; PostgreSQL is polled.
-- MySQL: block until this replica has executed the session's GTID set,
-- or time out after 1 second (returns 0 on success, 1 on timeout).
SELECT WAIT_FOR_EXECUTED_GTID_SET(@committed_gtids, 1);# PostgreSQL: poll the replay position within a deadline, else use primary
import time
def read_your_writes(query, token, replicas, primary, timeout_ms=500):
deadline = time.monotonic() + timeout_ms / 1000
while time.monotonic() < deadline:
r = pick_replica(token, replicas)
if r is not None:
return r.execute(query)
time.sleep(0.02)
return primary.execute(query) # bounded fallback — primary is always currentInline verification: On MySQL, WAIT_FOR_EXECUTED_GTID_SET returns 0 once the replica applies the set. In the Python path, log which branch served the read; the primary-fallback branch should fire rarely under healthy lag.
Step 5: Enforce monotonic reads
Advance the stored token to the highest replay position you have actually read from, so a later read can never be served by a replica that is behind an earlier read — preventing time from appearing to run backward.
def after_read(served_replay_lsn: str) -> None:
current = _write_token.get()
if current is None or lsn_gt(served_replay_lsn, current):
_write_token.set(served_replay_lsn) # watermark never regressesInline verification: Issue two reads in a row against a fleet with mixed lag. The second read must land on a replica at or past the first read’s position; assert the token is non-decreasing across the pair.
Configuration Snippet
MySQL primary and replica settings that make GTID-based read-your-writes reliable:
# my.cnf on primary and every replica
[mysqld]
gtid_mode = ON
enforce_gtid_consistency = ON
# Replicas apply in parallel to keep the catch-up window short
replica_parallel_type = LOGICAL_CLOCK
replica_parallel_workers = 8
# Preserve commit order so GTID visibility is monotonic on the replica
replica_preserve_commit_order = ONPostgreSQL replica settings that expose an accurate replay position and keep lag low:
# postgresql.conf on each replica
hot_standby = on
hot_standby_feedback = on # reduce query cancellations during catch-up
max_standby_streaming_delay = 15s
# Report replay position frequently so the router sees fresh data
wal_receiver_status_interval = 1sVerification and Rollback
Confirm the guarantee holds
-- On the primary: capture a token
SELECT pg_current_wal_lsn(); -- e.g. 0/3A2F1B8
-- On a replica: it must reach that position before you route the read there
SELECT pg_last_wal_replay_lsn() >= '0/3A2F1B8'::pg_lsn AS caught_up;Drive an end-to-end check from application code: perform a write, immediately issue the dependent read, and assert the value reflects the write across a few hundred iterations. Any failure means the token is not being captured, stored, or compared correctly.
-- MySQL end-to-end assertion inside a test harness
SELECT WAIT_FOR_EXECUTED_GTID_SET(@committed_gtids, 1); -- expect 0
SELECT display_name FROM users WHERE id = @uid; -- expect the new valueRoll back safely
If token tracking misbehaves — for example the fallback rate spikes and overloads the primary — disable the replica gate and pin the affected reads to the primary while you diagnose:
- Set a feature flag
ryw_mode = primary_onlyso every gated read goes straight to the primary. Correctness is preserved; only read-scaling headroom is lost temporarily. - Confirm with
SELECT pg_is_in_recovery();returningfon the served connection for those reads. - Once replica lag and the token-comparison path are healthy again, flip the flag back to
replica_gatedand watch the fallback-rate metric return to baseline.
Because the primary is always current, routing more reads to it is always a safe, correctness-preserving fallback — the only cost is throughput.
Edge Cases and Gotchas
The token must be monotonic, or reads go backward
If you overwrite the session token with each new position instead of taking the maximum, a read served by a slightly-behind replica can lower the watermark and let the next read land on an even older snapshot. Always advance the token to max(current, new). This is the difference between plain read-your-writes and the stronger monotonic-reads guarantee, and it costs nothing to add. The freshness-based routing decision tree treats the monotonic watermark as the minimum acceptable replay position for each dispatch.
LSN comparison is not string comparison
A PostgreSQL LSN like 0/3A2F1B8 is a two-part hex value. Comparing tokens as plain strings breaks at boundaries — 0/9FFFFFF sorts after 0/A000000 lexically but is numerically smaller. Compare with pg_wal_lsn_diff() on the database side, or cast to the pg_lsn type, or parse both halves as integers in application code. The same care applies to MySQL GTID sets, which are unions of intervals and must be compared with GTID_SUBSET() semantics, not string equality.
Connection poolers can hide the write connection
In transaction-pooling mode, the connection that ran your write is released back to the pool before you read pg_current_wal_lsn(), so you may capture a position from a different backend. Capture the LSN in the same transaction or statement batch as the write, before the pooler reclaims the connection. When you push routing into the application, ORM middleware for automatic query routing is the natural place to record the commit token, because it already intercepts the write on its way out.
FAQ
Is read-your-writes the same as strong consistency?
No. Read-your-writes is a session guarantee: a client always sees its own prior writes, but it may still observe stale data written by other clients. Strong consistency guarantees every reader sees the latest committed state globally, which requires routing all reads to the primary or a synchronous replica. Read-your-writes lets most reads stay on replicas and only pays the primary-read cost for the narrow window after a session’s own write.
How large should the LSN wait timeout be?
Size the wait timeout to your p99 replication lag under normal load, typically 100 to 800 milliseconds. Waiting longer than that ties up a request thread for a case that should be rare, so fall back to the primary once the timeout expires rather than blocking indefinitely. Track how often the fallback fires; a rising fallback rate is an early signal that replica lag is degrading.
Does read-your-writes work across multiple devices for the same user?
Only if the LSN or GTID token is stored in a shared, cross-device store rather than a single client cookie. A write from a phone advances a token that a web session must also see, so the token has to live in a distributed store such as Redis keyed by user ID. If each device keeps its own token, the guarantee holds per device but not across the user’s whole account.
Related
← Back to Evaluating Consistency Models for Distributed Reads
- Routing Queries Based on Data Freshness Requirements — the decision tree for classifying reads by staleness tolerance and enforcing the replay-position thresholds this guarantee depends on.
- ORM Middleware for Automatic Query Routing — where to capture the commit token and gate replica selection in-process, with causal-read LSN handling.
- Understanding Synchronous vs Asynchronous Replication — the commit-path trade-off that creates the stale window read-your-writes closes.
- Replication Lag & Consistency Management — measuring the lag that sets your wait timeout and fallback rate.