The Desi Architect
Notes / Docker Sandboxes / PostgreSQL Streaming Replication Lag and Read-Your-Own-Writes
Docker Sandboxes · Tier 2: Data & Scale · Lab 23

PostgreSQL Streaming Replication Lag and Read-Your-Own-Writes

A user saves their profile, the app reads it back from a replica, and the old profile comes back. Make that stale read happen on a real primary and replica, see how far behind the replica is in bytes, and fix it with read-your-own-writes.

Time40 minutes
StackPostgreSQL 16Ports 15432, 15433
MemoryAbout 120 MB
Before thisLab 04SQL Basics with PostgreSQL
Free previewVerified runWindows, Mac, LinuxBash and PowerShell
  1. 1PrepareWhat you will learn
  2. 2PredictGuess before you run
  3. 3RunStep by step
  4. 4ObserveWhat you should see
  5. 5Break itChange one thing
  6. 6Clean upLeave nothing running
On this page
  1. 00 / What You Will Learn
  2. 01 / Why It Matters
  3. 02 / What We Are Building
  4. 03 / Words You Need
  5. 04 / Predict First
  6. 05 / Step by Step
  7. 06 / What You Should See
  8. 07 / Look Inside
  9. 08 / Break It, Fix It
  10. 09 / In Real Projects
  11. 10 / Interview Corner
  12. 11 / Clean Up

00 /What You Will Learn

By the end you will be able to
  • Explain a primary, a replica, the WAL and an LSN in plain words.
  • Check that streaming replication is really running, and that it is asynchronous.
  • Make a replica fall behind on purpose, and measure the lag in bytes.
  • Reproduce a stale read, and say why the user sees it.
  • Fix it with read your own writes, using the WAL position of the write.

Before you start

Finish Lab 02 (Docker Compose in Practice) and Lab 04 (SQL Basics) first. You should be comfortable with docker compose up, with a table and an UPDATE. You do not need to know anything about replication yet.

Lab-only passwords

The passwords in this lab (postgres_password, replicator_password) exist only on your laptop. Real databases never use them.

01 /Why It Matters

Your app is popular and one database cannot answer all the reads. So you add copies. One primary takes every write. One or more replicas copy each change and answer reads. Most of your traffic is reads, so this works well, and almost every big system does it.

Copying takes time. For a moment, a replica is behind the primary. Now picture Aditi on a job site. She updates her profile and presses Save. The write goes to the primary. The page reloads and reads her profile from a replica that has not caught up. She sees her old profile. She thinks the save failed and presses Save again, or leaves. You did nothing wrong, and she is unhappy.

In plain words

Think of a live cricket broadcast from Eden Gardens. The official scorekeeper at the pitch inputs "SIX" on the scorer's terminal (Primary). The stadium scoreboard updates in 50 milliseconds. But a viewer watching on mobile streaming in Jaipur (an asynchronous read replica with 3 seconds of network buffer lag) is still watching the bowler begin their run-up. If the viewer bets with a friend based on their delayed screen, they act on stale state. Similarly, if a user updates their account password on the primary, and an immediate login query routes to a replica that is 500ms behind, the login fails with "Invalid Password".

The lag is usually a few milliseconds, which is why this bug hides in testing. It shows up on the bad day: a big sale, a long running query on the replica, a slow disk, a network hiccup. This lab makes the bad day happen on demand.

02 /What We Are Building

Two PostgreSQL 16 containers. The replica starts by copying the primary (pg_basebackup), then follows its WAL stream. It is a hot standby: it can answer reads while it follows.

Replication lag lab topology Lab script lag_demo.py writes primary lab-pg-primary, 15432 WAL stream replica lab-pg-replica, 15433 reads
  • WAL (write-ahead log): the ordered list of every change the primary makes. Replicas replay it.
  • LSN: a position in the WAL, like a page number in a diary. We compare LSNs to see who is ahead.
  • pg_stat_replication on the primary shows each replica and how far it has got.
Under the Hood: Streaming replication internals and LSN phases

The walsender to walreceiver pipeline

On the primary, a dedicated walsender background process reads new WAL records and streams them over a persistent TCP connection. On the replica, the walreceiver process writes the incoming bytes to local disk, while the startup process continuously reads and replays those WAL records into shared memory buffers.

The four replication lag checkpoints

PostgreSQL tracks progress through four LSN checkpoints: sent_lsn (sent over network), write_lsn (written to replica kernel page cache), flush_lsn (durable on replica disk via fsync), and replay_lsn (actually replayed and visible to read queries). True replication lag equals pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn).

Read-Your-Own-Writes session sticky routing

To prevent users from seeing stale reads after updating data, modern architectures use session stickiness. When a client performs a write mutation, the web tier sets a session cookie or cache token. Subsequent read queries from that specific user session are pinned directly to the Primary for 5 seconds, while all other read traffic continues hitting replicas safely.

How long-running replica queries cause replication lag

In PostgreSQL streaming replication, the replica continuously replays WAL records from the primary. If an analytical query or table scan runs on the read replica, replaying a WAL record that modifies or drops rows in that table would invalidate the running query snapshot. Under default settings, PostgreSQL pauses WAL replay on the replica until the query completes (up to max_standby_streaming_delay). During this pause, the replica falls behind the primary in transaction history. Application queries directed to this replica begin returning stale data. Once the long query terminates, the replica startup process must replay the backlog of accumulated WAL changes, causing elevated disk I/O until it catches up with the primary.

03 /Words You Need

WordPlain meaning
PrimaryThe database that takes the writes.
Replica (standby)A copy that follows the primary and answers reads.
WALThe write-ahead log: every change, in order.
LSNA position in the WAL.
Replication lagHow far behind the replica is, in time or in bytes of WAL.
Stale readReading old data from a replica that has not caught up.
Async replicationThe primary says "committed" without waiting for replicas. Fast, and replicas can be behind.
Read your own writesAfter a write, make sure the next read by the same user sees it.
LatencyHow long a query takes to execute from invocation to response.
ThroughputThe number of transactions processed per second.
TransactionA group of database operations that succeed or fail together as a single unit.
SnapshotA consistent point-in-time view of database data visible to a query or transaction.
CheckpointA point in the WAL sequence where dirty data pages are flushed from RAM to disk.

04 /Predict Before You Run

Question 1: You commit a change on the primary. Is it on the replica at that exact moment?

Not guaranteed. With asynchronous replication the primary says "committed" first and ships the change afterwards. Usually it arrives within milliseconds. Under load or a slow replica it can take much longer.

Question 2: The replica stops applying changes for ten minutes. Does it also stop receiving them?

No. In this lab replay is paused, but the changes still arrive and wait. The gap between what was received and what was applied is the lag. When replay resumes, the replica catches up by itself. If the replica were disconnected instead, it would also catch up later by reading the WAL it missed, as long as the primary still keeps it.

Question 3: How can your app avoid showing a user their own old data?

After each write, remember the WAL position of that write (an LSN). Before reading from a replica, ask the replica how far it has replayed. If it is past that position, the replica is safe for this user. If not, read from the primary.

05 /Step by Step

Step 0: Get the lab

Download the zip with the button at the top of this page. Unzip it into a new folder and open a terminal inside that folder. Every command below runs from there.

Which terminal are you using?
mkdir postgres-replication-lag && cd postgres-replication-lag
unzip ~/Downloads/postgres-replication-lag.zip
mkdir postgres-replication-lag; cd postgres-replication-lag
Expand-Archive -Path "$HOME/Downloads/postgres-replication-lag.zip" -DestinationPath .

To run all five steps with checks, use ./test-replication-lag.sh or .\test-replication-lag.ps1. To explore by hand, start the lab and open a session on each database:

docker compose up -d --wait
docker exec -it lab-pg-primary psql -U postgres -d desi_shop
# in a second terminal
docker exec -it lab-pg-replica psql -U postgres -d desi_shop

Step 1: Is it streaming?

-- on the primary
SELECT client_addr, state, sync_state, sent_lsn, replay_lsn FROM pg_stat_replication;

You should see: one row, state = streaming, sync_state = async.

Step 2: Healthy path

-- on the primary
UPDATE users SET bio = 'Updated just now' WHERE id = 101;

-- on the replica, a moment later
SELECT bio FROM users WHERE id = 101;

You should see: the replica shows Updated just now. The replica is read only: try an UPDATE there and you get an error.

Step 3: Make it fall behind

-- on the replica
SELECT pg_wal_replay_pause();

-- on the primary
UPDATE users SET bio = 'Promoted to VP of Engineering' WHERE id = 101;
SELECT pg_current_wal_lsn();
SELECT sent_lsn, replay_lsn, pg_wal_lsn_diff(sent_lsn, replay_lsn) AS bytes_not_applied FROM pg_stat_replication;

-- on the replica
SELECT bio FROM users WHERE id = 101;

You should see: bytes_not_applied greater than 0, and the replica still showing the old bio. That is the stale read, on a real replica.

Step 4: The fix, read your own writes

The app took pg_current_wal_lsn() right after its write. Now it asks the replica:

-- on the replica
SELECT pg_last_wal_replay_lsn();

-- compare the two positions (on either server)
SELECT pg_wal_lsn_diff('<replica position>', '<write position>') >= 0;

You should see: f (false): the replica has not reached the write. The app reads from the primary instead, and the user sees their own change. The lab does exactly this with a small function, read_your_writes.

Step 5: Resume

-- on the replica
SELECT pg_wal_replay_resume();

You should see: the lag drops to 0 within a second or two and the replica shows the promotion. The same check now returns t (true), and the replica can serve this user again.

06 /What You Should See

The real output of the automated run (test-replication-lag).

Terminal · test-replication-lag
== Starting the primary and the replica (the replica copies the primary first, this takes a little while) ================================================================== == Step 1: Is the replica really streaming from the primary? ================================================================== client_addr | state | sync_state | sent_lsn | replay_lsn --------------+-----------+------------+-----------+------------ 192.168.32.3 | streaming | async | 0/3000000 | 0/3000000 (1 row) [OK] the primary lists one replica in state 'streaming' [OK] sync_state is 'async': the primary does not wait for the replica before it says 'committed' [OK] the replica reports pg_is_in_recovery = true (it is a standby, read only) ================================================================== == Step 2: Healthy path: write to the primary, read from the replica ================================================================== write on primary, then polling the replica: it showed the new value after 308 ms (this includes the docker exec time) [OK] on a healthy system the replica shows the change within a moment ================================================================== == Step 3: Make the replica fall behind, and watch a stale read happen ================================================================== [OK] WAL replay on the replica is paused (the replica still receives the changes, it just does not apply them) the write is at WAL position 0/30002F8 sent_lsn | write_lsn | replay_lsn | bytes_not_applied -----------+-----------+------------+------------------- 0/30002F8 | 0/30002F8 | 0/3000260 | 152 (1 row) sent = the primary sent it, write = the replica wrote it to its disk, replay = the replica applied it so queries can see it [OK] the replica has received and stored the change (write position has reached the write) [OK] the replica has received the change but applied 152 bytes less than it should primary says : Promoted to VP of Engineering at 01:24:40 replica says : Updated at 01:24:39 [OK] STALE READ: the primary has the promotion and the replica still shows the old text. A user who just saved would see their old profile ================================================================== == Step 4: The fix: read your own writes ================================================================== The app remembers the WAL position of its write. Before it reads from a replica, it asks: has this replica reached that position? read goes to the primary: Promoted to VP of Engineering at 01:24:40 [OK] the replica was behind, so the read was sent to the primary and the user saw their own change ================================================================== == Step 5: Resume the replica: it catches up by itself ================================================================== [OK] replay resumed and the unapplied WAL dropped to 0 bytes [OK] the replica now shows the promotion too. Replication lag is temporary, a stale read is what it does to unprepared code read goes to the replica: Promoted to VP of Engineering at 01:24:40 [OK] now the replica has passed that position, so the read can use the replica again and still be correct ================================================================== ALL STEPS PASSED
Observed on a laptop, not a production guarantee

The numbers above come from one run on one laptop with Docker Desktop. Your machine will show different timings and counters. What must match is the shape of the result, the [OK] lines.

07 /Look Inside

1. See the replica's own view

Run (on the replica): SELECT pg_is_in_recovery(), pg_is_wal_replay_paused(), pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn();

You should see: t, then t or f, and two positions. With replay paused, the receive position is ahead of the replay position.

2. Try to write to the replica

Run (on the replica): UPDATE users SET bio = 'x' WHERE id = 101;

You should see: ERROR: cannot execute UPDATE in a read-only transaction. All writes go to the primary.

3. See how the replica was set up

Run: docker exec lab-pg-replica ls /var/lib/postgresql/data | grep standby

You should see: standby.signal. That file tells PostgreSQL to start as a standby that follows another server.

08 /Break It, Then Fix It

Predict: pause the replica, write ten changes to the primary, then resume. Is the lag in bytes bigger or smaller than for one change, and how long does catch-up take?

Answer: bigger, roughly in proportion to the amount written. Catch-up is fast for small writes (milliseconds) and takes longer as the backlog grows. In real systems a big batch job on the primary is a classic cause of lag.

Stretch: after pausing, read from the replica in a loop while you resume it, and note the moment the answer changes. That is your lag in time.

09 /What Changes in Real Projects

  • Monitor lag in both seconds and bytes. Alert when it passes what your users can tolerate. Managed services expose it as a metric (for example ReplicaLag in Amazon RDS).
  • Long queries on a replica can cause lag or get cancelled. The replica must choose between applying changes and keeping old rows for the query (max_standby_streaming_delay).
  • Decide per screen. A product list can be a little stale. "My balance" and "my order" cannot. Send those reads to the primary, or use the LSN check from this lab.
  • Sticky routing is simpler. Many apps send a user to the primary for a few seconds after their write. It is less exact than the LSN check and easy to build.
  • Synchronous replication removes the lag and slows every commit. The next lab shows that tradeoff.
The tradeoff

Replicas give you read capacity and cost you freshness. Fresh reads mean going to the primary (less scaling), waiting for the replica (slower reads), or tracking positions (more code). Stale data is fine for a product list and not for "my balance". Decide per screen, on purpose.

10 /Interview Corner

  1. What is replication lag? The delay between a change on the primary and the same change being visible on a replica. It is measured in time, or in bytes of WAL not yet applied.
  2. Why do stale reads happen? With asynchronous replication the primary commits before replicas have applied the change, so a replica read can return old data.
  3. How do you give read-your-own-writes consistency? Send those reads to the primary, pin the user to the primary for a short time after a write, or compare the WAL position of the write with the replica's replay position before using the replica.

Follow-up questions the interviewer will ask

  • How would you route reads so a user always sees their own writes?
  • What happens to a replica that is offline for an hour?
  • What does synchronous replication change, and what does it cost?

11 /Clean Up

docker compose down -v

The automated script cleans up after itself. Next: Lab 24, Synchronous versus Asynchronous Replication. What happens to a commit when the primary waits for the replica.