The last five lessons were all layers that sit in front of the database. This one steps into the database itself, and the first question is the cheapest in the whole series: if your read-to-write ratio is 100:1 — a very common number — then simply adding copies to read from already handles 99% of the work.
This lesson's lab stands up a PostgreSQL primary and a real read replica, with streaming replication. And the very first measurement produced a number I did not see coming: the lab's replication lag is only 0.557 ms — less than half a thousandth of a second — and yet the share of users who "write, read back, and see stale data" was still 87.21%. Lag does not need to be large to break a system; it only needs to be larger than the gap between that same user's write and their next read.
Because both PostgreSQL nodes sit on the same machine, the natural lag is under 1 ms — far smaller than for a replica in another geographic region. So besides the measurements at natural lag, this lesson also uses
recovery_min_apply_delay to impose a controlled lag of 200 ms,
simulating a distant replica. Every table states which mode it was measured in.
7.1 Three different purposes, usually collapsed into one
"Add a replica" sounds like one job, but it serves three different goals — and those three goals need three different configurations. Collapsing them is the source of most disappointment with replicas.
| Purpose | What it needs | What it does NOT give you |
|---|---|---|
| Scaling the read tier | Several replicas, async is enough, tolerate slightly stale data | No help at all with write throughput — every write still funnels into one primary. It does not increase storage capacity either |
| High availability (HA) | At least semi-synchronous, plus election and fencing, plus a replica in another region | You do not get it for free by enabling an async replica and promoting by hand during an incident |
| A backup copy | A deliberately delayed copy, or point-in-time snapshots, or a logical backup somewhere else entirely | A replica is not a backup. See the pitfall right below — this is the most expensive misconception of the lot |
DELETE FROM orders with the missing WHERE you just ran. In the lab the lag is
0.557 ms. That is roughly half a thousandth of a second to regret it.A replica protects you from hardware failure. A backup protects you from a logic error — yours, a deploy's, or an attacker's. Two different kinds of incident need two different tools. If all you have is a replica then you do not have a backup, and you will find that out at the worst possible moment.
There is something in between, and it is well worth knowing: a deliberately delayed replica. Using exactly the mechanism this lesson uses to simulate lag —
recovery_min_apply_delay — you can keep a replica permanently one hour behind. It is
still a replica, but it gives you an hour to notice a bad delete and stop it.
7.2 The replication mechanism, and the price of each safety level
PostgreSQL does not copy "the data", it copies the WAL (write-ahead log) — the journal stream recording every change before it is applied. The replica receives that stream and replays it. MySQL calls it the binlog; the idea is the same.
What matters comes down to a single question: at what moment does the primary answer "write committed"? Answer early and writes are fast but data can be lost when the primary dies; answer late and it is safer, but every write has to wait for the replica.
The figures in the diagram come from pgbench — the load-measuring tool that ships with every
PostgreSQL installation, nothing extra to install. It opens N connections, loops one SQL script for T
seconds, and reports transactions per second (tps) and the average latency of a transaction. Here it runs
inside the primary container with 8 connections for 10 seconds, and the script is a single
INSERT — the less work inside the transaction, the closer the rest is to the pure cost of the
commit step, which is exactly what the four synchronisation modes change:
cd blog/sysdesign/sysdesign-lab
docker compose --profile replica up -d
# The smallest possible write script
docker compose exec -T postgres sh -c 'echo "INSERT INTO write_load (filler)
VALUES (repeat(md5(random()::text), 8));" > /tmp/w.sql'
# Switch mode, then measure again. `walreceiver` is the replica's application_name
# in this lab — read it from: SELECT application_name FROM pg_stat_replication;
for cfg in "'':local" "walreceiver:remote_write" \
"walreceiver:on" "walreceiver:remote_apply"; do
names="${cfg%%:*}"; mode="${cfg##*:}"
docker compose exec -T postgres psql -U lab -d lab -q \
-c "ALTER SYSTEM SET synchronous_standby_names = $names;" \
-c "ALTER SYSTEM SET synchronous_commit = '$mode';" \
-c "SELECT pg_reload_conf();"
echo "== $mode"
docker compose exec -T postgres pgbench -U lab -d lab -n -f /tmp/w.sql -c 8 -j 2 -T 10
done
remote_apply measured 0.811 ms — very slightly faster than
on at 0.836 ms, even though in theory it waits for more (for the replica to finish
applying, not just flushing). That 0.025 ms gap is within run-to-run variation, so the correct
conclusion is that these two modes cost the same in this lab, not that remote_apply is
cheaper.The reason: both nodes are on the same machine, so the "apply" step is nearly free next to the "flush to disk" step. With a replica in another region that distance widens sharply and
remote_apply becomes distinctly more expensive. This is a good example of something to keep
in mind throughout the series: the lab removes geographic distance, so it
measures the mechanism correctly but not the proportions.
synchronous_standby_names points at a replica in another geographic region, every write
has to wait a full cross-region RTT. With the number from Lesson 6 —
150 ms for a long route — maximum write throughput on one connection drops to about 6 transactions per
second, no matter how powerful the machine is.The signature is very distinctive: write throughput collapses while the database's CPU stays idle. Nothing is computing — everything is waiting on the network. If you see that combination, check
synchronous_standby_names before you go off
optimising queries.
7.3 Replication lag, and the bug every replicated system meets
This is the heart of the lesson. The familiar scenario: a user changes their avatar — the write goes to the primary. The page reloads — the read goes to a replica. If the replica has not applied the change yet, they see the old avatar and conclude the system is broken. That is a read-your-writes violation: you must always be able to read back what you yourself just wrote.
The figure below names the two intervals that decide this, and both symbols are used for the rest of the section: Δ (delta) is the replication lag — how long the replica needs to finish applying a change that just happened on the primary. ε (epsilon) is the gap between that same user's write and their next read. If ε is larger than Δ the replica has caught up and the user sees fresh data; if ε is smaller than Δ they see stale data. That is all there is to it — and note that both are intervals, so what decides the outcome is the ratio between them, not whether Δ is large or small.
The lab has a /rww endpoint that does exactly two things: write to the primary (using
RETURNING version so it knows precisely which version it wrote), then immediately read from
the replica. If the version read back is lower than the version just written, that is stale data
— measurable, not a matter of impression. This is the smallest ε possible: the two statements sit right
next to each other inside one request, with nothing in between. The three counters rwwPinned,
rwwTotal and rwwStale are exactly where the percentages in the table below come
from:
// 1) WRITE to the primary. RETURNING tells us exactly which version we just wrote.
const wrote = await pgPrimary.query(
`UPDATE profiles SET avatar = 'avatar-v' || (version + 1) || '.png',
version = version + 1, updated_at = now()
WHERE id = ${id} RETURNING version`
);
const wroteVersion = Number(wrote[0] && wrote[0].version);
if (usePin && READ_PIN_MS > 0) readPin.set(id, Date.now() + READ_PIN_MS);
// 2) READ IMMEDIATELY. This is the decisive moment: the replica may not have caught up.
const pinned = usePin && shouldReadPrimary(id, Date.now());
if (pinned) rwwPinned++;
const target = pinned ? pgPrimary : pgReplica;
const read = await target.query(`SELECT version, avatar FROM profiles WHERE id = ${id}`);
const readVersion = Number(read[0] && read[0].version);
rwwTotal++;
// "Stale" is MEASURABLE, not a feeling: reading a version LOWER than the one we
// just wrote is not open to argument.
const stale = readVersion < wroteVersion;
if (stale) rwwStale++;
Four configurations, each with 16 connections for 12 seconds, about 100,000 write-then-read rounds:
| Configuration | replay_lag |
Stale-read rate | Throughput | p99 |
|---|---|---|---|---|
| A · natural lag, no pinning | 0.557 ms | 87.21% (90,376/103,627) | 7,547 rps | 4.71 ms |
| B · lag forced to 200 ms, no pinning | 200.3 ms | 100.00% (102,625/102,625) | 7,516 rps | 4.88 ms |
| C · lag 200 ms, pinned to primary for 500 ms | 200.2 ms | 0.00% (0/98,358) | 7,200 rps | 5.16 ms |
| D · lag 200 ms, pinning disabled per request | 200.9 ms | 100.00% | 7,295 rps | 5.10 ms |
Row A is the one worth stopping on. A lag of 0.557 ms — a number no dashboard would alert on, and many people would call "essentially no lag" — still made 87% of reads return stale data. Because the app writes and then reads inside the same request, the gap ε between the two statements is only a few tens of microseconds. Against that ε, half a millisecond is a very long time.
Row C shows the fix working absolutely: 0 out of 98,358. And the price is surprisingly cheap — throughput falls from 7,547 to 7,200, which is 4.6%. Row D is the control: the same configuration but with pinning disabled per request sends the rate straight back to 100%, proving it is the pinning mechanism itself that made the difference and not some other incidental change.
The second fix: let the database handle it
There is another way that needs no application code changed at all: set
synchronous_commit = remote_apply. The primary then answers "write committed" only after the
replica has finished applying it, so every subsequent read on the replica sees fresh data.
Measured for real, at natural lag, with no pinning whatsoever:
| Fix | Stale-read rate | /rww throughput |
What it really costs |
|---|---|---|---|
| No fix | 87.21% | 7,547 rps | — |
| Pin reads to the primary (application layer) | 0.00% | 7,200 rps (−4.6%) | More code and more state; only correct for the person who just wrote |
remote_apply (database layer)
|
0.00% | 6,594 rps (−12.6%) | Every write gets slower, including for people who never read back; pure writes −42% tps |
Both fixes give 0%, but they pay for it in two different places.
Pinning at the application layer only slows down the reads of someone who just wrote — a
very small share of traffic. remote_apply slows down every write in
the system, including those of people who never read anything back. That is why the application-layer fix
is the more common one, even though it means writing code.
Map in the app process's memory. It gives 0% because
/rww writes and reads inside the same request, so it is always the same
process.A real system is not like that: the user writes (the request lands on app1) then reloads the page (the request lands on app2). app2 knows nothing about that write and reads the replica anyway — the bug is back. And it comes back in the most annoying way possible: it only happens to a fraction of users, it cannot be reproduced on demand, and it disappears when you try it on a dev machine with a single app replica. Same family as the local-state bug in Lesson 3 section 3.5.
Three ways to make the pin state shared, from simple to strict: (1) put a flag in the user's own cookie or token — needs no extra infrastructure, and it naturally follows the right person; (2) store it in Redis (already present since Lesson 5); (3) strictest of all, pass around the LSN — the position in the WAL — that the user needs to see, then only read from a replica that has replayed up to that position, comparing with
pg_last_wal_replay_lsn(). Option (3) needs no time window
and no guessing, but it does require carrying the LSN through the API layer.
This is a monotonic reads violation, and it is the worst kind of bug to deal with: the user reports "it keeps jumping around", you try it ten times and it is fine every time, and no log records anything because no request failed. The usual fix is to pin each session to a fixed replica — that guarantees nothing about freshness, but it does guarantee the data never goes backwards. For a user, "a bit stale but stable" is far easier to accept than "jumping back and forth".
Measuring lag correctly
PostgreSQL exposes three different kinds of lag, and only one of the three is the number a reader actually
feels. The lab's tools/pg-lag.sh script asks the primary directly; here is its query portion,
quoted verbatim:
# write_lag time until the replica has WRITTEN the WAL to disk
# flush_lag time until the replica has FLUSHED it (durable)
# replay_lag time until the replica has APPLIED it ← THIS is what a reader sees
docker compose exec -T postgres psql -U lab -d lab -x -c "
SELECT application_name,
state,
sync_state,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn)) AS unsent,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS unreplayed,
COALESCE(write_lag::text, '-') AS write_lag,
COALESCE(flush_lag::text, '-') AS flush_lag,
COALESCE(replay_lag::text, '-') AS replay_lag
FROM pg_stat_replication;" 2>/dev/null
Two columns worth noting: unsent is how much WAL the primary has not sent yet, and
unreplayed is how much WAL the replica has received but not applied. The three lag columns
behind them are wrapped in COALESCE because PostgreSQL returns NULL when there
has been no transaction to measure — and NULL here means "not known", not "zero".
pg_last_xact_replay_timestamp() while the system is idle
now() - pg_last_xact_replay_timestamp() on the replica. The problem: when
there is no write traffic, that number grows steadily over time even though the replica
has caught up perfectly — because it measures "how long since the last transaction was replayed", not
"how far behind are we".The practical consequence: an alert built on that formula fires at 3 in the morning, exactly when the system is quietest and nothing is wrong. In the lab this measured 0.944 seconds while the real
replay_lag was 0.000465 seconds — two thousand times apart.The right way: alert on
replay_lag from pg_stat_replication on the primary
side, or on the LSN distance (unreplayed in the script above). Both are zero when the
replica has genuinely caught up.
7.4 Failover and split-brain — building the disaster by hand
When the primary dies, a replica has to be promoted to become the new primary. It sounds simple, and PostgreSQL does it with exactly one statement. The problem is not the promoting, it is the question: how do you know for certain that the old primary really is dead?
The lab lets you reproduce the worst case exactly. We cut the network between the replica and the primary — simulating a partition, with the primary still alive and still accepting writes — and then promote the replica with no mechanism guaranteeing the old primary has stopped:
cd blog/sysdesign/sysdesign-lab
# Before: the replica is a standby and REFUSES every write
docker compose exec -T postgres-replica \
psql -U lab -d lab -tAc "SELECT pg_is_in_recovery();"
# t
# 1) CUT THE NETWORK between replica and primary (primary is STILL ALIVE, still writing)
docker network disconnect sysdesign-lab_default sysdesign-lab-postgres-replica-1
# 2) PROMOTE the replica — no fencing, nobody confirmed the old primary is dead
docker compose exec -T postgres-replica \
psql -U lab -d lab -tAc "SELECT pg_promote(wait => true);"
docker compose exec -T postgres-replica \
psql -U lab -d lab -tAc "SELECT pg_is_in_recovery();"
# f ← it is a primary now
# 3) BOTH now accept writes for the SAME row
docker compose exec -T postgres psql -U lab -d lab -tAc \
"UPDATE profiles SET avatar='tu-PRIMARY-CU.png', version=version+100
WHERE id=500 RETURNING version, avatar;"
# 762|tu-PRIMARY-CU.png
docker compose exec -T postgres-replica psql -U lab -d lab -tAc \
"UPDATE profiles SET avatar='tu-PRIMARY-MOI.png', version=version+200
WHERE id=500 RETURNING version, avatar;"
# 862|tu-PRIMARY-MOI.png
# 4) Reconnect the network. Can it reconcile itself?
docker network connect sysdesign-lab_default sysdesign-lab-postgres-replica-1
docker compose logs postgres-replica | tail -4
# LOG: selected new timeline ID: 2
# LOG: archive recovery complete
# LOG: database system is ready to accept connections
The result: for the same row id=500, the old primary holds
762 | tu-PRIMARY-CU.png and the new one holds 862 | tu-PRIMARY-MOI.png (those
two strings are just labels the write statements set — Vietnamese for "from-OLD-PRIMARY" and
"from-NEW-PRIMARY" — so you can tell at a glance which node a row came from). The data has
diverged. And reconnecting the network reconciles nothing — the log line
selected new timeline ID: 2 is PostgreSQL itself telling you that history has forked in two.
docker network commands and one
pg_promote — worth doing once, to see how easily it happens.
Once the network recovers you have two sets of data, both valid, with no way to reconcile them automatically. One side's data has to be thrown away, and during the divergence clients on both sides were told "success".
That is why every trustworthy failover system has three things: fencing (making sure the old node cannot write), quorum (a majority decides who is primary, rather than each node deciding for itself), and a witness (a third node whose only job is to break a 1–1 tie). Without fencing, the other two cannot save you either.
7.5 Operating it: what to watch, and the classic conflict
The replica is the most easily forgotten component in a system, because when it works nobody sees it, and when it falls behind the symptom shows up somewhere else entirely — users report "wrong data", nobody reports "the replica is slow".
| Watch | Why | Start alerting at |
|---|---|---|
replay_lag, per replica |
This is the number a reader actually feels | When it exceeds the threshold your application logic assumes (the pin window, for instance) |
| Unreplayed LSN distance | It does not suffer the "grows while idle" artefact that the time-based measure does | Any value that climbs continuously without coming back down |
Row count in pg_stat_replication |
A disconnected replica vanishes from this view entirely — complete silence | Fewer than the number of replicas you think you have |
| WAL still retained on the primary |
If a replica falls too far behind and the WAL has been deleted, it has to
pg_basebackup from scratch — that is, copy the entire data directory from the
primary again, because there is no longer enough journal left to catch up by replaying; hours on a
large database
|
When WAL volume approaches wal_keep_size |
| Do NOT alert on the replica's CPU | Replaying WAL is a single-threaded process: a replica can fall a very long way behind while total CPU still looks perfectly idle | — |
The problem: an analytical query scanning a whole table competes for I/O and memory with the WAL replay process, making lag balloon. And ballooning lag means the user-facing read path starts returning stale data — an internal report has just broken the customer experience. Worse, PostgreSQL may also have to cancel that very query (conflict with recovery) or postpone replay to keep it running, depending on
hot_standby_feedback and
max_standby_streaming_delay.The right answer is almost always a separate replica for analytics, outside the pool serving users. It costs more money, and that is the entire content of this decision: you are buying separation between two kinds of load with completely different requirements.
Reproduce the measurements yourself
cd blog/sysdesign/sysdesign-lab
docker compose --profile replica up -d # real primary + replica, streaming replication
# Check that replication is actually running
./tools/pg-lag.sh
docker compose exec -T postgres-replica \
psql -U lab -d lab -tAc "SELECT count(*) FROM profiles;" # 1000
docker compose exec -T postgres-replica \
psql -U lab -d lab -tAc "UPDATE profiles SET version=99 WHERE id=1;"
# ERROR: cannot execute UPDATE in a read-only transaction ← correct, replica is read-only
# --- A: stale-read rate at the NATURAL lag ---
./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js \
--url "http://lb:8080/rww" -c 16 -d 12 -w 2 --json
./tools/cache-stats.sh # look at the READ-YOUR-WRITES line
# --- B: force a CONTROLLED 200ms lag, simulating a replica in another region ---
docker compose exec -T postgres-replica psql -U lab -d lab \
-c "ALTER SYSTEM SET recovery_min_apply_delay = '200ms';" -c "SELECT pg_reload_conf();"
./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js \
--url "http://lb:8080/rww" -c 16 -d 12 -w 2 --json
./tools/cache-stats.sh # 100%
# --- C: turn the pin-to-primary mechanism on ---
# set READ_PIN_MS to '500' for app1/app2/app3 in docker-compose.yml, then:
docker compose --profile replica up -d --force-recreate app1 app2 app3
./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js \
--url "http://lb:8080/rww" -c 16 -d 12 -w 2 --json
./tools/cache-stats.sh # 0%
# --- D: control run. Same pinned setup, but ?pin=0 disables the pin per request,
# which proves the pin is what made the difference and not something else.
./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js \
--url "http://lb:8080/rww?pin=0" -c 16 -d 12 -w 2 --json
./tools/cache-stats.sh # back to 100%
# Restore the default
docker compose exec -T postgres-replica psql -U lab -d lab \
-c "ALTER SYSTEM SET recovery_min_apply_delay = '0';" -c "SELECT pg_reload_conf();"
pg_basebackups itself from scratch:docker compose --profile replica down -v then
docker compose --profile replica up -d.The
-v flag deletes the volumes — which means deleting the primary's data as well. In the
lab that is fine because the schema is reloaded automatically from postgres/init/, but
remember it is a destructive command.
In summary
A replica serves three different purposes that need three different configurations, and a replica is not a backup — in the lab you get 0.557 ms to regret a bad delete. Choosing a synchronisation level is choosing a point on the durability ↔ write-latency axis: going from async to sync, write latency rose 79% and write throughput fell 44%.
The biggest lesson is row A of the table in section 7.3. A replication lag of 0.557 ms — a number every dashboard would show in green — still broke read-your-writes on 87% of reads. Because what decides the outcome is not whether the lag is large or small, but whether it is larger than the gap between that same user's write and their read. Inside a web request, that gap is measured in microseconds.
Both fixes give exactly 0%: pinning reads to the primary (application layer, −4.6% throughput) and
synchronous_commit = remote_apply (database layer, −12.6%). They differ in where they pay:
the application-layer fix only slows down the person who just wrote, while the database-layer fix slows
down every write in the system.
Lesson 8 asks the question replicas cannot answer: what happens when the writes themselves exceed what one machine can take, or when the data no longer fits on one disk. That is when you have to shard — the most expensive step in the whole series in terms of complexity, and also the one people reach for too early most often.
📖 References
-
PostgreSQL —
synchronous_commit: the official definition of all five levelsoff/local/remote_write/on/remote_applymeasured in section 7.2 -
PostgreSQL — Synchronous Replication, including the syntax of
synchronous_standby_namesand the warning about having only one synchronous standby -
PostgreSQL — the
pg_stat_replicationview: the precise meaning ofwrite_lag,flush_lagandreplay_lag, used in "Measuring lag correctly" -
PostgreSQL —
recovery_min_apply_delay: the mechanism used to impose the controlled 200 ms lag, and also the way to build the deliberately delayed replica from section 7.1 -
PostgreSQL —
pg_promote()and the recovery control functions used in the split-brain experiment (section 7.4) - PostgreSQL — Write-Ahead Logging: why the WAL exists, and why it is the thing that gets replicated rather than the data itself
- Jepsen — Consistency Models: the formal definitions of read-your-writes and monotonic reads, the two properties broken in this lesson (and the groundwork for Lesson 9)
Download the lab source
The two files this lesson reads from directly: the hand-written, dependency-free PostgreSQL client (the app uses it to talk to both the primary and the replica), and the lag-measuring script quoted verbatim in "Measuring lag correctly":
Download minipg.js (0-dependency PostgreSQL client) Download pg-lag.sh
Comments