With two followers at different lag, a user's repeated reads can move backward in time — a count goes 1 then 0 for the same query as the refresh lands on a laggier replica. You reproduce it on two real Postgres replicas, then see why "monotonic reads" is the guarantee that forbids it.
Replication lag causes a second, stranger anomaly than a stale read: with two followers at different lag, a user's repeated reads can move backward in time. Read once from a caught-up replica and you see a new comment; refresh, get routed to a laggier replica, and the comment is gone — the second read observes an older state than the first. You will reproduce it on two real Postgres replicas — one fresh, one lagged 5 s — and watch a count go 1 then 0 for the same query, then see why "monotonic reads" is the guarantee that forbids it.
Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 6 — Replication, §"Monotonic reads":
it's possible for a user to see things moving backward in time … the second query observes the system state at an earlier point in time than the first.
The book describes a user refreshing a page and each request hitting a random replica. Here you stage exactly that and watch time reverse.
The surprise is about reads bouncing between replicas at different lag. These help; each line notes where it shows up.
pg_stat_replication shows each standby's lag. If only one is new, make it #3 — monotonic reads is the exact property that "time went backward" breaks.
postgres:16 (PostgreSQL 16.14): one primary + two async standbys — one fresh, one lagged 5 s (recovery_min_apply_delay).
setup_replication.sh gives you a primary and two followers: ddia-ch6-standby2 (fresh — applies WAL immediately) and ddia-ch6-standby (lagged 5 s). A user reads the same query twice; the first read lands on the fresh replica, the refresh lands on the laggy one. In the 5-second window after a write, the fresh replica has the new comment and the laggy one doesn't — so the user's two reads disagree, newer then older. The setup is scripted; the reads you run by hand.
cd study/ddia/ch06/code
bash setup_replication.sh # primary + fresh standby (…-standby2) + 5s-lagged standby (…-standby)
Do not read the output yet — make each prediction first.
Write a comment to the primary and wait ~1 second — long enough for the fresh follower to apply it, but well inside the laggy follower's 5 s delay:
docker exec ddia-ch6-primary psql -U postgres -d study -c \
"INSERT INTO comments VALUES (2, 'a hot take');"
sleep 1
Predict. The user runs SELECT count(*) WHERE id = 2 twice. The first read is routed to the fresh replica; the refresh is routed to the laggy one. What does each return?
# first read -> fresh replica:
docker exec ddia-ch6-standby2 psql -U postgres -d study -c "SELECT count(*) FROM comments WHERE id=2;"
# refresh -> laggy replica:
docker exec ddia-ch6-standby psql -U postgres -d study -c "SELECT count(*) FROM comments WHERE id=2;"
1, then 0. The user saw the comment, refreshed, and it disappeared — the second read observed the database at an earlier moment than the first. Nothing was deleted; the two reads simply hit replicas at different points in the replication stream.
Predict. How do you stop a user's reads from going backward without making every replica current?
Observe. Give monotonic reads: route each user's reads to the same replica (e.g. hash the user ID to a follower), so their successive reads always advance through one replica's timeline and never jump to a laggier one:
# both reads for this user go to the SAME replica -> the second never predates the first
docker exec ddia-ch6-standby2 psql -U postgres -d study -c "SELECT count(*) FROM comments WHERE id=2;"
docker exec ddia-ch6-standby2 psql -U postgres -d study -c "SELECT count(*) FROM comments WHERE id=2;"
# -> 1, then 1
One replica may itself be behind, but a single replica's timeline only moves forward — so the user never un-sees data.
A leader can have many followers, and they lag independently — one might be current while another is seconds behind, depending on load, network, and (here) a deliberate apply delay. A stateless load balancer happily sends a user's first request to one follower and their refresh to another. Each follower is internally consistent — it shows some past state of the database — but two different followers show two different past states. When the newer one answers first and the older one answers second, the user watches the database run backward: a comment appears, then un-appears. It's the read-your-writes lag from the previous exercise, seen through a second lens — not "my own write is missing" but "the timeline jumped backward between two reads."
Monotonic reads is the precisely-scoped guarantee that rules this out. It does not promise you the latest data (that would forfeit async replication's benefit); it promises only that your reads never regress — once you've seen a state, you won't later see an older one. The standard implementation is sticky routing: map each user to one replica (hash their ID) so all their reads traverse a single follower's monotonically-advancing timeline. That follower can be behind the leader, and different users can be pinned to different followers at different lag — none of that matters, because within one user's session the state only moves forward.
SELECT application_name, replay_lag FROM pg_stat_replication on the primary during the window. Predict which standby shows ~5 s of lag and which shows ~0.Sources: DDIA 2e, Ch. 6, §"Monotonic reads", §"Solutions for Replication Lag" · the PostgreSQL streaming replication docs (pg_stat_replication).