studyDDIAChapter 6 · Replication

Refresh the Page, and the Comment Is Gone

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.


Concept

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.

Provenance

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.

Prerequisites

The surprise is about reads bouncing between replicas at different lag. These help; each line notes where it shows up.

  1. Multiple followers, different lag — a leader can have many followers, each independently behind by its own amount. A load balancer may route successive reads to different ones.
  2. Replication lag (again) — the delay between a leader commit and a given follower reflecting it; here one follower is fresh, one is 5 s behind.
  3. Monotonic reads — the guarantee that if a user reads a newer state, a later read never shows them an older one. Weaker than reading the latest value; stronger than nothing. The property being violated.
  4. Sticky routing — pinning a user's reads to one replica (e.g. by hashing the user ID), the usual way to provide monotonic reads. The fix.
Where to learn the prerequisites The anomaly and its fix (#3–#4): DDIA §"Monotonic reads" and §"Solutions for Replication Lag". Multiple replicas at different lag (#1–#2): the PostgreSQL streaming replication docs; 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.
Environment these numbers came from The violation is staged deterministically (fresh replica sees the write, lagged replica doesn't, within the 5 s window). Captured on macOS 26.5.2 (Darwin 25.5.0), arm64, Docker 29.6.2, postgres:16 (PostgreSQL 16.14): one primary + two async standbys — one fresh, one lagged 5 s (recovery_min_apply_delay).

Mental model

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.

Setup

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.


Step 1 · write, then let only the fresh replica catch up

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
Step 2 · read twice, land on different replicas (the surprise)

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;"
What does each read return — and which way does time run?
first read -> routed to the FRESH replica: count(id=2) = 1 (sees the comment) refresh -> routed to the LAGGY replica: count(id=2) = 0 (comment is GONE -- time moved backward)

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.

Step 3 · the fix: stick the user to one replica

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.


What you should see

Why

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.

The boundary — it's a per-user timeline guarantee, not global freshness Monotonic reads is deliberately weak: it says nothing about seeing the newest write (that's read-your-writes or stronger), only that a single reader's view doesn't rewind. That weakness is what makes it cheap — you keep serving stale followers, you just stop switching which stale follower a given user sees. If the pinned replica dies you must fail the user over to another (possibly at a different point in time, momentarily breaking the guarantee), and truly global "everyone sees a consistent, advancing timeline" needs stronger machinery (consistent-prefix / causal consistency, Ch. 6 and 10). Match the guarantee to the anomaly: read-your-writes for "my own write," monotonic reads for "don't rewind," and reach for the heavy tools only when you actually need them.

Go deeper

  1. Random routing, repeated. Alternate reads between the two standbys in a loop for ~10 s after a write. Predict the pattern of 1s and 0s as the laggy replica slowly catches up — the flicker a real random-routed user would see.
  2. Lag from the leader's view. Run 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.
  3. Sticky by hashing. Write a tiny router that hashes a user ID to one of the two standbys and always reads there. Predict whether any user can still see time go backward (they can't — that's the point) and what happens the moment their replica restarts.

Sources: DDIA 2e, Ch. 6, §"Monotonic reads", §"Solutions for Replication Lag" · the PostgreSQL streaming replication docs (pg_stat_replication).