studyPostgreSQL › Replication Lag

Replication Lag

Write a million rows to the primary and watch the replica report a count of 1. Then freeze the replica mid-stream to see that "durable on the replica" and "visible on the replica" are two different numbers — and make a COMMIT hang forever.


Concept

A streaming replica is a second Postgres that replays the primary's write-ahead log as it arrives. It is never quite the same database as the primary — there is always some amount of WAL in flight between them, and during that window the replica will happily answer a query with an older answer. This exercise makes that window visible and measurable: you'll write a million rows to the primary and watch the replica report a count of 1, watch the byte distance between the two nodes climb into the tens of megabytes and drain back to zero, freeze the replica mid-stream so the lag stops being a blur, and then flip replication to synchronous and watch a COMMIT on the primary block until the replica says it's caught up — and then hang forever when the replica dies.

The headline surprise, if you only take one thing The replica does not gradually count up to a million. It sits at the old number and then jumps. A replica is never half a transaction behind — it is always a consistent snapshot of some past moment of the primary.

Provenance

PostgreSQL 16 documentation, Chapter 27, "High Availability, Load Balancing, and Replication".

§27.2.8, "Synchronous Replication" — the claim Part 4 tests. On the default setting:

This configuration will cause each commit to wait for confirmation that the standby has written the commit record to durable storage.

And on the setting that changes the answer:

Setting synchronous_commit to remote_apply will cause each commit to wait until the current synchronous standbys report that they have replayed the transaction, making it visible to user queries. In simple cases, this allows for load balancing with causal consistency.

Those two sentences are the whole exercise — durable and visible are different waits. The same section states the failure mode Part 4 ends on:

Such transaction commits may never be completed if any one of the synchronous standbys should crash.

§27.4, "Hot Standby" — the claim Part 1 tests: "All such connections are strictly read-only; not even temporary tables may be written." And the query-conflict behaviour in "Go deeper": "a mechanism is provided to forcibly cancel standby queries that conflict with to-be-applied WAL records."

§28.2, the pg_stat_replication view — the definitions Part 3 leans on. replay_lag is "Time elapsed between flushing recent WAL locally and receiving notification that this standby server has written, flushed and applied it." Note what that does not say: it is not the age of the data on the standby. The docs also warn:

If the standby server has entirely caught up with the sending server and there is no more WAL activity, the most recently measured lag times will continue to be displayed for a short time and then show NULL.

Prerequisites

Two ideas carry most of the result. Neither is explained by the Postgres replication chapter itself, and both are the reason the observations surprise people.

  1. MVCC visibility rules — the one that matters most. Part 2's whole point is that a count on the replica jumps from 4000001 to 7000001 in one step, and the reason is not replication at all: a row version is visible only once its creating transaction is known to have committed, and the commit is a single WAL record at the end of three million insert records.
  2. WAL and LSNs as a byte stream. Every number in Parts 2 and 3 is a subtraction of two byte offsets into a single append-only log. If "0/46D266D0 minus 0/44F113C8 is 30 MB" doesn't read as obvious arithmetic yet, start there.

One smaller thing, used only in Part 3: the standby reports its position on a timer, wal_receiver_status_interval (default 10 s). That single default explains the shape of the replay_lag staircase in step 11.

Where to learn the prerequisites MVCC (the core): PostgreSQL §13.1, Transactions and MVCC, or the exercise next door, MVCC and vacuum. WAL and LSNs: PostgreSQL §30.6, WAL Internals, or run WAL and crash recovery first.
Environment these numbers came from
On the numbers LSNs, byte distances and wall-clock timings are genuinely run-variable — they depend on how fast your host writes and how busy it is. Every figure below is real output from the run described here, but treat the magnitudes and the shapes as the claim, not the digits. On a lightly loaded host the burst in step 7 peaked around 18 MB behind; on a loaded one the same burst peaked at 144 MB. The claim that survives both is "it climbs into the tens of MB and drains to exactly zero".

Mental model: four LSNs, four different promises

An LSN (Log Sequence Number) is a byte offset into the WAL stream, written as 0/46D266D0. Every change to the database appends to that stream, so "how far behind is the replica" is literally a subtraction of two byte offsets — which is what pg_wal_lsn_diff() does.

The important thing is that a WAL record makes four separate journeys, and pg_stat_replication on the primary has a column for each. They are not the same event and they do not carry the same guarantee:

ColumnReached when…What is actually guaranteed at this point
sent_lsn the primary's walsender has pushed the bytes onto the network Nothing about the replica at all. This is the primary's opinion.
write_lsn the replica's walreceiver has write()n them to the OS Survives the replica's postgres process crashing. Does not survive the replica's machine losing power — the bytes are in the OS page cache.
flush_lsn the replica has fsync()ed them to disk Survives the replica's machine dying. The data is durable on two nodes. But it is not yet queryable on the replica — this is the distinction Part 3 is built around.
replay_lsn the replica's startup process has applied the records The change is now visible to SELECTs on the replica.
primary network replica ─────── ─────── ─────── walsender ──sent_lsn──▶ [ bytes in flight ] ──▶ walreceiver │ write_lsn (in OS cache) │ flush_lsn (on disk — DURABLE) │ replay_lsn (applied — VISIBLE)

Alongside those, write_lag / flush_lag / replay_lag report the same three distances as time rather than bytes — how long it took the most recent locally-flushed WAL to reach each stage. Useful, but Part 3 shows a case where replay_lag badly understates how stale the replica really is.

The replica has its own view, from the other side: pg_last_wal_replay_lsn() (same number as the primary's replay_lsn) and pg_last_xact_replay_timestamp() (the commit time of the last transaction it replayed — subtract it from now() for an honest staleness figure).

Where the commit is allowed to return

By default, replication is asynchronous: the primary commits, returns to the client, and the WAL makes its way over whenever it makes its way over. Setting synchronous_standby_names makes at least one standby synchronous, and then synchronous_commit decides which of the four journeys above a COMMIT waits for:

synchronous_commitCommit returns once…Reach for it when…
off the WAL is in the primary's own buffer — not even flushed locally You can tolerate losing the last ~0.2 s of committed transactions on a primary crash. Bulk loads, analytics staging, click-tracking. Note this risks data loss with no replica involved.
local the primary has fsynced locally. Standbys ignored. Per-transaction escape hatch: you have sync replication on globally, but this one bulk job shouldn't pay for it. SET synchronous_commit = local; inside that session.
remote_write the standby's write_lsn has passed the commit You want to survive the standby's process dying, but not a full standby power loss, and you don't want to pay for its fsync. An unusual middle ground.
on (default) the standby's flush_lsn has passed the commit The normal choice when you want zero data loss on primary failure. The transaction is durable on two machines before the client hears "ok".
remote_apply the standby's replay_lsn has passed the commit You are load-balancing reads onto the replica and cannot tolerate read-your-writes violations. The most expensive setting — you pay the replay time on every commit.
The gap that matters With on, your committed transaction is durable on the replica but not yet visible there. Part 4 demonstrates exactly that, by making one setting block and the other not, with nothing else changed.

Setup

Two Postgres containers on a private Docker network, so they can find each other by name. This is the one part of the exercise worth scripting — get the cluster up, then type everything after it by hand.

docker network create pgnet

1. The primary.

docker run -d --name pg-primary --network pgnet \
  -e POSTGRES_PASSWORD=postgres postgres:16

Wait a few seconds for it to initialize. The defaults are already replication-ready — wal_level = replica and max_wal_senders = 10 — so there is nothing to tune. Note there is no -p: nothing in this exercise connects over a published port, every psql goes through docker exec.

2. A replication role, and permission to use it.

docker exec -i pg-primary psql -U postgres \
  -c "CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'replpass';"

docker exec -i pg-primary bash -c \
  "echo 'host replication replicator all scram-sha-256' >> /var/lib/postgresql/data/pg_hba.conf"

docker exec -i pg-primary psql -U postgres -c "SELECT pg_reload_conf();"
The line everyone forgets replication is a pseudo-database in pg_hba.conf, and the stock file only allows it from localhost. A rule granting all databases does not cover it — you need an explicit host replication line or pg_basebackup is rejected.

3. Take a base backup into a named volume.

docker volume create pg-replica-data

docker run --rm --network pgnet \
  -e PGPASSWORD=replpass -v pg-replica-data:/pgdata \
  --entrypoint bash postgres:16 -c \
  "install -d -o postgres -g postgres -m 0700 /pgdata/pgdata && \
   gosu postgres pg_basebackup -h pg-primary -U replicator -D /pgdata/pgdata -Fp -Xs -R -P"
waiting for checkpoint 23182/23182 kB (100%), 0/1 tablespace 23182/23182 kB (100%), 1/1 tablespace

pg_basebackup is a physical, byte-for-byte copy of the primary's data directory. -Xs streams WAL concurrently with the copy so the backup is self-consistent, and -R is the flag that matters here: it writes standby.signal and a primary_conninfo line into the new data directory, which is what turns a copy into a standby.

Why the install -d dance A fresh Docker volume is owned by root and mode 0755; Postgres refuses to start on a data directory it doesn't own with exactly 0700 (or 0750). Creating the directory with the right owner and mode up front is what avoids a data directory "/var/lib/postgresql/data" has invalid permissions failure at step 4.

4. Start the standby.

docker run -d --name pg-replica --network pgnet \
  -v pg-replica-data:/var/lib/postgresql/data \
  -e PGDATA=/var/lib/postgresql/data/pgdata \
  postgres:16

The official entrypoint sees an already-initialized PGDATA and skips initdb, so it just starts Postgres — which finds standby.signal and comes up in recovery. Using a named volume (rather than baking the backup into the container's own command) matters for Part 4, where you stop and start this container and it needs to survive that.

Check the log with docker logs pg-replica:

LOG: entering standby mode LOG: consistent recovery state reached at 0/2000100 LOG: database system is ready to accept read-only connections LOG: started streaming WAL from primary at 0/3000000 on timeline 1

That last line is the one you want. Now open two terminals:

Terminal P — primary

docker exec -it pg-primary psql -U postgres

Terminal R — replica

docker exec -it pg-replica psql -U postgres
Keep both open — this is not cosmetic Several steps depend on firing a query the instant another one returns, and a fresh docker exec costs a few hundred milliseconds of container startup — enough for the replica to catch up and hide the effect you came to see. Step 6 is the one that fails if you get this wrong.

Part 1 — what a replica is

What this part establishes: a standby is a whole running Postgres that happens to be permanently in recovery. That single fact explains both what it can do (serve reads) and what it can't (accept any write at all).

Step 1 · which end am I talking to?
Replica
SELECT pg_is_in_recovery();
pg_is_in_recovery ------------------- t (1 row)

On the Primary the same query returns f. This boolean is the cheapest way to ask a connection "am I talking to the primary or a replica?", and it's what most connection poolers use to route writes.

Step 2 · the link, from the primary's side
Primary\x first, this is wide:
SELECT application_name, client_addr, state,
       sent_lsn, write_lsn, flush_lsn, replay_lsn,
       write_lag, flush_lag, replay_lag, sync_state
FROM pg_stat_replication;
-[ RECORD 1 ]----+------------ application_name | walreceiver client_addr | 172.19.0.3 state | streaming sent_lsn | 0/3000060 write_lsn | 0/3000060 flush_lsn | 0/3000060 replay_lsn | 0/3000060 write_lag | flush_lag | replay_lag | sync_state | async

All four LSNs are identical because the system is idle — there is no WAL in flight for them to disagree about. The three lag columns are null, not zero, and that is documented behaviour rather than "nothing has happened yet": per §28.2, once the standby has entirely caught up the last measured lag is shown for a short while and then goes NULL. Run this query within a second or two of starting the standby and you'll catch real sub-millisecond values instead; wait a few seconds and they blank out. And sync_state | async is the default — the primary is not waiting for this replica for anything.

Note this view lives on the primary. Run it on the replica and you get zero rows, which confuses everyone once.

Step 3 · create something to replicate
Primary
CREATE TABLE events (id bigserial PRIMARY KEY, payload text,
                     created_at timestamptz DEFAULT clock_timestamp());
INSERT INTO events (payload) VALUES ('hello from the primary');
Replica
SELECT id, payload FROM events;
id | payload ----+------------------------ 1 | hello from the primary (1 row)

The DDL replicated too. Physical replication ships WAL, and CREATE TABLE is WAL — there is no schema/data distinction at this level.

Step 4 · try to write to it
Replica
INSERT INTO events (payload) VALUES ('hello from the replica');
The replica is a fully functional Postgres with the whole table in it, and you are connected as superuser. What happens?
ERROR: cannot execute INSERT in a read-only transaction

And CREATE TABLE t (x int); gives:

ERROR: cannot execute CREATE TABLE in a read-only transaction

This isn't a permissions check on the postgres role — it's structural. The replica's WAL position is a single linear pointer into the primary's stream; a local write would fork history and there would be no way to continue replaying. So every write path is refused, superuser or not. The docs put it flatly: "All such connections are strictly read-only; not even temporary tables may be written."

Step 5 · the replica's own view of where it is
Replica
SELECT pg_last_wal_replay_lsn(), pg_last_xact_replay_timestamp();
pg_last_wal_replay_lsn | pg_last_xact_replay_timestamp ------------------------+------------------------------- 0/30655B8 | 2026-07-25 16:23:23.414661+00 (1 row)

These are the two functions you actually use in production monitoring, because they work from the replica — where your read traffic is — rather than requiring a connection to the primary.


Part 2 — the burst

What this part tests: how big the in-flight window really is. Per the mental model, sent_lsn - replay_lsn is a byte distance, so a large enough write should make it large enough to see.

Step 6 · a million rows, then read immediately
Primary
INSERT INTO events (payload) SELECT 'burst-'||g FROM generate_series(1,1000000) g;
Replica — the instant it returns:
SELECT count(*) FROM events;
The replica saw 1 row before this. What count comes back — something part-way through the burst, or one of the endpoints?
count ------- 1 (1 row)

One. Not 400,000, not 999,000 — the same number as before the burst. Meanwhile, on the Primary:

SELECT pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS behind
FROM pg_stat_replication;
behind -------- 16 MB (1 row)

16 MB of WAL exists that the replica has not applied. Wait a few seconds, re-run the count on the replica, and it is 1000001.

Speed matters here, and it's the one place the exercise is fragile. The insert itself took 1.8 s, and the replica applies WAL the whole time it is running — so what you are catching is only the tail. If you type the count into a fresh docker exec instead of the already-open terminal R, the few hundred milliseconds of container startup are enough for the replica to finish, and you'll see 1000001 and conclude there's no lag at all.

Step 7 · watch the drain instead of sampling it
Primary — leave this running (\watch re-runs a query on an interval):
SELECT clock_timestamp()::time(3) AS t, sent_lsn, replay_lsn,
       pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS behind,
       replay_lag
FROM pg_stat_replication \watch 0.5

Then from a third shell (or reuse the replica's terminal with a docker exec into the primary), fire a bigger burst:

INSERT INTO events (payload) SELECT 'burst2-'||g FROM generate_series(1,3000000) g;
How far apart do sent_lsn and replay_lsn get, and does the gap close smoothly?

The repeated \watch header block between iterations is trimmed here; each line is one iteration:

16:23:34.839 | 0/C6CC740 | 0/C6CC740 | 0 bytes | 00:00:00.06938 16:23:35.338 | 0/C6CC740 | 0/C6CC740 | 0 bytes | 00:00:00.06938 16:23:35.839 | 0/C6CC740 | 0/C6CC740 | 0 bytes | 00:00:00.06938 16:23:36.341 | 0/C6CC778 | 0/C6CC778 | 0 bytes | 00:00:00.001624 16:23:36.838 | 0/C6CC778 | 0/C6CC778 | 0 bytes | 00:00:00.001624 16:23:37.337 | 0/E000000 | 0/CECD858 | 17 MB | 00:00:00.250322 16:23:37.836 | 0/11000000 | 0/107FFFE8 | 8192 kB | 00:00:00.121514 16:23:38.336 | 0/13400000 | 0/12FFFFD8 | 4096 kB | 00:00:00.239075 16:23:38.837 | 0/16C00000 | 0/159C5470 | 18 MB | 00:00:00.20663 16:23:39.339 | 0/193FE000 | 0/193FDFE0 | 32 bytes | 00:00:00.141107 16:23:39.836 | 0/1CC00000 | 0/1BFFFFF0 | 12 MB | 00:00:00.129262 16:23:40.339 | 0/1F400000 | 0/1F3FFFB8 | 72 bytes | 00:00:00.090136 16:23:40.836 | 0/22000000 | 0/21FFFFD0 | 48 bytes | 00:00:00.152997 16:23:41.339 | 0/25000000 | 0/23FFFFB0 | 16 MB | 00:00:00.261945 16:23:41.839 | 0/27400000 | 0/273FFFD0 | 48 bytes | 00:00:00.100813 16:23:42.337 | 0/28A04938 | 0/28A04938 | 0 bytes | 00:00:00.114559 16:23:42.841 | 0/28A04938 | 0/28A04938 | 0 bytes | 00:00:00.114559 16:23:43.339 | 0/28A04938 | 0/28A04938 | 0 bytes | 00:00:00.114559 16:23:43.843 | 0/28A04938 | 0/28A04938 | 0 bytes | 00:00:00.114559 16:23:44.34 | 0/28A04938 | 0/28A04938 | 0 bytes | 00:00:00.114559 16:23:44.839 | 0/28A04938 | 0/28A04938 | 0 bytes | 00:00:00.114559

Both LSNs march forward together, with the replica sawtoothing between roughly 18 MB behind and a handful of bytes behind, then settling at 0 bytes the moment the burst ends. Steady state on an idle link is exact equality, not "close enough".

The sawtooth is the shape to notice: replay isn't a smooth trickle, it proceeds in bursts as WAL segments arrive and get applied. The peak depends entirely on how loaded your host is — this run sawtoothed against 18 MB, an otherwise-busy run of the identical burst climbed in one hump to 144 MB before draining. What is not run-variable is the last two lines: once writes stop, the two LSNs become the same number.

Also look at replay_lag — it stays around 0.09–0.26 s the entire time, even in the rows where the replica is 16 MB behind. Bytes-behind and time-behind are genuinely different measurements: the replica is far behind in volume while staying close behind in wall-clock, because it is applying continuously and just can't keep up with the rate.

Step 8 · watch the count itself
Replica
SELECT clock_timestamp()::time(3) AS t, count(*) FROM events \watch 0.3
Primary
INSERT INTO events (payload) SELECT 'atomic-'||g FROM generate_series(1,3000000) g;
During those seconds when the replica is 16 MB behind, a count(*) on it should return something part-way through the burst. Should it not?

Headers trimmed; the middle of the run is elided, and every sample in it reads 4000001:

16:23:51.386 | 4000001 16:23:51.716 | 4000001 16:23:52.043 | 4000001 … 16:23:59.344 | 4000001 16:23:59.601 | 4000001 16:23:59.893 | 4000001 16:24:00.229 | 7000001 16:24:00.506 | 7000001 16:24:00.797 | 7000001 16:24:01.087 | 7000001

Eight and a half seconds at 4000001 — thirty consecutive samples — and then a single step to 7000001. Never 5200000. Never anything in between.

This is the most important thing in the exercise, and it's the thing the phrase "replication lag" tends to hide. The replica was receiving and applying those three million row-insert WAL records the whole time — you just watched replay_lsn climb through them in step 7. But row versions only become visible when the transaction that created them is known to have committed, and the commit record is a single WAL record at the very end. Replaying it flips all three million rows into visibility at once.

A replica is a consistent past, not a partial present MVCC on the replica is the same MVCC as on the primary, so a replica is never "half a transaction" behind — it is always a consistent snapshot of some past moment of the primary. That's a much better guarantee than "it's a bit behind", and a much worse one for your latency graph: a single huge transaction means the replica is stale by its entire duration, no matter how fast your network is.

Part 3 — freeze the replica

What this part tests: the distinction between flush_lsn and replay_lsn from the mental model — durable versus visible. Both drain to zero instantly on a healthy local link, so the only way to look at them properly is to stop one of them. pg_wal_replay_pause() does exactly that: the walreceiver keeps receiving and flushing, but the startup process stops applying.

Step 9 · stop time on the replica
Replica
SELECT count(*) FROM events;
SELECT pg_wal_replay_pause();
SELECT pg_get_wal_replay_pause_state();
count --------- 7000001 (1 row) pg_get_wal_replay_pause_state ------------------------------- paused (1 row)
Step 10 · write with the replica frozen
Primary
INSERT INTO events (payload) SELECT 'paused-'||g FROM generate_series(1,200000) g;

SELECT sent_lsn, write_lsn, flush_lsn, replay_lsn,
       write_lag, flush_lag, replay_lag,
       pg_size_pretty(pg_wal_lsn_diff(sent_lsn, flush_lsn))  AS flush_behind,
       pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS replay_behind
FROM pg_stat_replication;
The replica is not applying WAL. Is it also not receiving it? What do you expect flush_lsn and replay_lsn to say?
-[ RECORD 1 ]-+---------------- sent_lsn | 0/46D266D0 write_lsn | 0/46D266D0 flush_lsn | 0/46D266D0 replay_lsn | 0/44F113C8 write_lag | 00:00:00.037965 flush_lag | 00:00:00.043601 replay_lag | 00:00:00.244351 flush_behind | 0 bytes replay_behind | 30 MB

There it is: the four LSNs split into two groups. sent, write and flush are all at 0/46D266D0 — every byte the primary produced is already fsync()ed onto the replica's disk, 0 bytes behind. And replay_lsn is stuck at 0/44F113C8, exactly where it was when you paused, 30 MB back.

Read that as a sentence Those 200,000 rows are durable on two machines and queryable on neither but one. If the primary's disk caught fire right now, nothing is lost. If you query the replica right now, they don't exist. Durability and visibility are separate properties, and this is the row of the mental-model table that most people collapse into one.
Step 11 · the same moment, from the replica
Replica
SELECT count(*) FROM events;
SELECT pg_last_wal_replay_lsn(),
       now() - pg_last_xact_replay_timestamp() AS staleness;
count --------- 7000001 (1 row) pg_last_wal_replay_lsn | staleness ------------------------+----------------- 0/44F113C8 | 00:00:20.135367 (1 row)

Still 7000001. The replica is serving reads perfectly happily — it is a completely healthy database, just one that is now twenty seconds in the past.

Now go back to the Primary and watch what replay_lag does while the stall continues:

SELECT clock_timestamp()::time(0) AS t, replay_lag,
       pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS replay_behind
FROM pg_stat_replication \watch i=5 c=10
16:24:28 | 00:00:14.461593 | 30 MB 16:24:33 | 00:00:14.461593 | 30 MB 16:24:38 | 00:00:27.074364 | 30 MB 16:24:43 | 00:00:27.074364 | 30 MB 16:24:48 | 00:00:37.089507 | 30 MB 16:24:53 | 00:00:37.089507 | 30 MB 16:24:58 | 00:00:47.102225 | 30 MB 16:25:03 | 00:00:47.102225 | 30 MB 16:25:08 | 00:00:57.089105 | 30 MB 16:25:13 | 00:00:57.089105 | 30 MB
replay_lag is a staircase, not a clock

It does climb — but only in ~10-second steps, holding the same value for two consecutive samples before jumping. That interval is not a coincidence: it is wal_receiver_status_interval, which defaults to 10s and is how often the standby volunteers its position. replay_lag can only be recomputed when a status message arrives, so between messages it is a stale reading of a stale reading. Two consequences:

Alert on now() - pg_last_xact_replay_timestamp() measured on the replica, or on pg_wal_lsn_diff bytes — which was pinned at a flat 30 MB for the entire stall and never lied for a moment.

Step 12 · unfreeze
Replica
SELECT pg_wal_replay_resume();
SELECT count(*) FROM events;
Does the replica have to re-fetch those 30 MB from the primary?
count --------- 7200001 (1 row)

By the time you can type the next query it has already caught up: back on the primary, replay_behind reads 0 bytes. The WAL was already on local disk (that's what flush_lsn was telling you), so catching up was pure CPU, no network — timed on a repeat run, the 30 MB backlog applied in about 0.4 s. Fire the count fast enough and you can still catch the old 7000001, which is a neat demonstration that the "instant" jump has a real, measurable width.


Part 4 — make it synchronous

What this part tests: the synchronous_commit table from the mental model. So far every commit on the primary has returned without the replica being consulted at all — that's what sync_state | async meant in step 2. Now make the primary wait, and find out precisely what it waits for.

Step 13 · promote the standby to synchronous
Primary
ALTER SYSTEM SET synchronous_standby_names = '*';
SELECT pg_reload_conf();
SELECT application_name, sync_state, sync_priority FROM pg_stat_replication;
application_name | sync_state | sync_priority ------------------+------------+--------------- walreceiver | sync | 1 (1 row)

asyncsync, with no restart — this is a reload-only setting. '*' means "any connected standby will do"; in production you name them, and the name is the standby's application_name.

Run these as separate statements. ALTER SYSTEM cannot run inside a transaction block, so pasting both onto one psql -c fails with:

ERROR: ALTER SYSTEM cannot run inside a transaction block
Step 14 · freeze replay again, then commit
Replica
SELECT pg_wal_replay_pause();
Primary
INSERT INTO events (payload) VALUES ('sync-A');
Replication is now synchronous and the replica is not applying anything. Does this INSERT block?
INSERT 0 1 real 0m0.086s

It returns immediately — 86 ms, and that is almost entirely docker exec startup. No blocking at all.

Because synchronous_commit defaults to on, and per the mental model on waits for the standby's flush_lsn. The pause stopped replay, not receipt — flush is still keeping up, so the commit's condition is satisfied. "Synchronous replication" out of the box guarantees durability on two nodes, and says nothing whatsoever about whether you can read your write on the replica.

Step 15 · change exactly one setting
Primary — replay is still paused on the replica:
SET synchronous_commit = remote_apply;
INSERT INTO events (payload) VALUES ('sync-B');
Same cluster, same replica, same statement — only the last row of the mental-model table instead of the fourth. What happens?
No output. The INSERT is still waiting — indefinitely, with the replica's replay paused.

The only thing that changed is which of the four LSNs the commit is waiting on. From a second connection to the primary you can see exactly what it is waiting on:

pid | state | wait_event_type | wait_event | query -----+--------+-----------------+------------+------------------------------------------ 206 | active | IPC | SyncRep | INSERT INTO events (payload) VALUES ('sy (1 row)

Leave it hanging and hit Ctrl-C:

postgres=# INSERT INTO events (payload) VALUES ('sync-B'); ^CCancel request sent WARNING: canceling wait for synchronous replication due to user request DETAIL: The transaction has already committed locally, but might not have been replicated to the standby. INSERT 0 1 postgres=#
You cancelled it and it committed anyway Read that transcript again: INSERT 0 1. Cancelling a synchronous commit only abandons the wait — the transaction was already durable locally before the wait began, and there is no way to un-commit it. So a client that times out and reports failure to its user may be reporting a transaction that is permanently in the database. Any application talking to a synchronous cluster has to treat a commit timeout as "unknown", never "failed". Resume replay on the replica and SELECT payload FROM events WHERE payload LIKE 'sync-%'; returns both sync-A and sync-B — the cancelled one really is there.
Step 16 · kill the standby

Resume replay on the replica first, and put synchronous_commit back to its default on. Then, in a shell:

docker stop pg-replica
Primary
INSERT INTO events (payload) VALUES ('during-outage');
Replication is synchronous with synchronous_standby_names = '*' and exactly one standby, which is now gone. What happens to this write?
No output, and none is coming. There is no timeout — this waits forever.

Open a second connection to the primary and look:

SELECT pid, state, wait_event_type, wait_event, left(query,40) AS query
FROM pg_stat_activity WHERE state = 'active' AND wait_event = 'SyncRep';
-[ RECORD 1 ]---+----------------------------------------- pid | 223 state | active wait_event_type | IPC wait_event | SyncRep query | INSERT INTO events (payload) VALUES ('du

wait_event = SyncRep, and SELECT count(*) FROM pg_stat_replication; returns 0 — there is no standby to satisfy the condition, so the condition can never be satisfied. This is a primary that is up, healthy, accepting connections, answering every read query instantly, and unable to commit a single write. The docs say so in one flat sentence: "Such transaction commits may never be completed if any one of the synchronous standbys should crash."

You just converted a replica outage into a primary outage This is the classic operational trap of synchronous replication with a single standby: you configured it to avoid losing data, and the cost is that losing the replica takes the whole cluster's write path down with it. The standard answer is at least two standbys with synchronous_standby_names = 'ANY 1 (s1, s2)', so any one of them can be down without stalling writes.
Step 17 · the escape hatch

From that second connection — reads and ALTER SYSTEM still work fine:

Primary
ALTER SYSTEM SET synchronous_standby_names = '';
SELECT pg_reload_conf();

The hung INSERT returns the instant the reload lands:

INSERT 0 1 real 0m10.437s

10.4 s here, which was exactly how long it took to type the escape hatch. Downgrading to asynchronous is a config reload, no restart, which is the thing to have committed to memory before you ever turn synchronous replication on.

Step 18 · bring the replica back
docker start pg-replica
Primary — after a few seconds:
SELECT application_name, state, sync_state,
       pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS behind
FROM pg_stat_replication;
application_name | state | sync_state | behind ------------------+-----------+------------+--------- walreceiver | streaming | async | 0 bytes (1 row)

It reconnected on its own and caught up, including during-outage — both nodes now agree on 7200004. The standby re-requests the stream from its last flushed position; because the primary still had those WAL segments on disk, it simply resumed. Note the word because — see "Go deeper" for what happens when it doesn't.


What you should see

A replica that answers SELECT pg_is_in_recovery() with t and refuses every write with cannot execute INSERT in a read-only transaction. A million-row burst on the primary that leaves the replica reporting a count of 1 while pg_stat_replication shows it 16 MB behind. A \watch on the primary showing sent_lsn and replay_lsn sawtoothing up to 18 MB apart and then locking to 0 bytes the moment writes stop. A count on the replica that sits at 4000001 for thirty consecutive samples over eight and a half seconds and then jumps to 7000001 in a single step, never passing through anything in between. With replay paused: flush_behind | 0 bytes next to replay_behind | 30 MB, a replica twenty seconds stale, and a primary reporting replay_lag | 00:00:00.244351 — which then climbs in a 10-second staircase to 00:00:57 while the replica's own staleness reads 00:01:15. And under synchronous replication, an INSERT that returns in 86 ms under synchronous_commit = on, blocks forever under remote_apply, commits anyway when you Ctrl-C it, and stalls the entire primary on wait_event = SyncRep when the standby dies.

Why

Physical streaming replication is one linear byte stream and nothing more. The primary appends WAL; the standby reads it, writes it, flushes it, and applies it. Every property in this exercise falls out of that one design.

The replica is read-only because its position in that stream is a single pointer. A local write would create WAL that does not exist on the primary, and the next record streamed in would have nowhere consistent to land. It's not a policy, it's the absence of any coherent alternative — which is why even a superuser can't override it.

The count jumps rather than climbs because visibility on the replica is decided by exactly the same MVCC rules as on the primary: a row version is visible once its creating transaction is known to have committed. Replay processes the three million insert records first and the single commit record last, so three million rows become visible in one instant. That's what makes a replica usable — it always shows a consistent past state of the primary, never a torn one — and it's also why a long transaction on the primary pins the replica's apparent staleness to that transaction's whole duration, regardless of bandwidth.

The four LSNs are four separate promises because there are genuinely four distinct moments, and different applications need different ones. Pausing replay just holds two of them still long enough to see they were never the same number. Once you accept that flush and replay are different events, synchronous_commit = on blocking on one and remote_apply blocking on the other stops being trivia and becomes the actual decision: do you need this write to be safe, or do you need it to be visible over there? The default answers "safe", and a read-your-writes bug on a read replica is what it costs — reproduced as a user-facing failure, on this same substrate, in ddia/ch06/read-your-writes.

The replay_lag staircase has the same root cause. Lag-as-time can only be computed from information the standby volunteers, and it volunteers on a timer. Lag-as-bytes is computed from sent_lsn, which the primary knows without asking anyone — which is precisely why it never went stale during the stall while the time-based figure did. When you want a monitoring signal that degrades gracefully, prefer the one whose inputs are local. And note where the effect vanishes: drop wal_receiver_status_interval to 1s and the staircase becomes fine-grained enough to look like a clock — at the cost of ten times the status traffic, which is exactly the tradeoff the default is making on your behalf.

And the hung primary in step 16 is the honest price of synchronous replication. synchronous_commit = on is a promise that no committed transaction can be lost if the primary dies — which is only keepable if the primary refuses to commit when it has nobody to tell. There is no timeout setting because a timeout would silently break the promise at the worst possible moment. The only real fix is more standbys, so that "nobody to tell" stops being one machine away.


Go deeper

Everything below was run against this same cluster; the quoted output is real, but the steps are left for you to reproduce. Run them in this order — promotion forks the timeline and ends the cluster's usefulness.

Sources: PostgreSQL 16 §27.2, Log-Shipping Standby Servers for synchronous_standby_names, synchronous_commit and the ANY N quorum syntax · §28.2, pg_stat_replication for the exact definition of the three lag columns · §27.4, Hot Standby for read-only standbys, query conflicts and hot_standby_feedback


Cleanup

docker rm -f pg-primary pg-replica
docker volume rm pg-replica-data
docker network rm pgnet