Replication is the durability escape hatch: if you can't trust one machine's disk, keep a second machine's disk warm. The elegant trick of WAL-based replication is that it reuses everything from crash recovery — the standby is just a primary that never stops recovering. The machinery: two background processes, one network socket, and a promise about when "COMMIT" means "safe everywhere."
Step 1 — COMMIT on the primary
The commit path runs exactly as in the commit trace: WAL buffer appended, fsync, commit record durable. The primary has now paid the durability price. Replication's job is to make the standby pay it too — from the primary's log, not by re-running SQL.
Step 2 — the walsender
Each standby connects to the primary and is served by a dedicated walsender process. The handshake: the standby reports its last received LSN; the walsender positions itself there and starts streaming WAL records over a TCP connection — as they're written. Records are sent in XLOG_DATA messages, typically batched with a configurable delay (wal_sender_timeout, 60s default, is a liveness check, not a batching knob — the stream sends as fast as the network allows). The standby is, in effect, a client reading the primary's write-ahead log in real time.
Step 3 — the walreceiver
On the standby, the walreceiver process receives the records, writes them to the standby's own WAL files, and fsyncs them (controlled by wal_receiver_create_temp_slot and standby fsync behavior — a standby that doesn't fsync received WAL can still lose data). Then it sends a flush ack: "records up to LSN X are safe on my disk." That ack is the message the entire sync/async distinction hangs on.
Step 4 — replay
The standby's startup process replays the WAL — the exact same code path as crash recovery (that's the reuse trick). The standby is permanently in recovery mode; the only difference is the WAL keeps arriving instead of ending. Replay applies page deltas to the standby's buffer pool; dirty pages are checkpointed normally.
Step 5 — hot standby: reading during replay
On hot standby, the startup process shares the data files with live readers. This is the hard part: a reader's snapshot must never see a half-replayed page or a row whose commit record hasn't replayed yet. The rules:
- Pages: the buffer pool's page LSN tracking ensures a reader never fetches a page with redo still pending (
page_in_hot_stopre-fetch logic). - Rows: a visible row version's commit status is consulted via the CLOG as of the snapshot — transactions still in progress on the primary are invisible on the standby until their commit record replays.
- The price: replay must be careful — one slow reader can force the startup process to wait (
max_standby_streaming_delaykicks in for queries that block replay, killing them after a timeout). Read replicas are "eventually consistent with a hard cap."
Step 6 — synchronous commit
With synchronous_standby_names = 'replica1', the COMMIT changes character: after the primary's fsync, the transaction waits for the standby's flush ack (or apply ack with remote_apply) before returning to the client. Cost: one extra network RTT on every commit — ~0.5-5ms on typical LANs. The guarantee: if the primary dies after this commit returned, the standby has the record and recovery will include it. With remote_apply, the standby has applied it — the newly-promoted node can serve it immediately. Async mode: commit returns after the primary's fsync only; a failover can lose the last few hundred ms of commits.
Step 7 — failover
When the primary dies: the standby runs pg_ctl promote (or pg_promote()): it stops replaying, writes an end-of-recovery record, and starts a new timeline (the timeline ID is part of every WAL record header). The history file records the switch. Then the old primary, if it comes back, finds its timeline is stale — the new primary's history forbids it from serving writes (it becomes the new standby or gets re-cloned via pg_rewind, which rolls its tail back to the divergence point using WAL). Split-brain protection is your configuration's job (fencing, synchronous_commit = remote_apply), not the database's.
What it costs
- Async streaming: ~0 commit latency overhead; lag bounded by network + replay speed.
- Sync commit (flush ack): +1 RTT per commit (0.5-5ms LAN, worse over WAN).
remote_apply: +1 RTT and replay delay — commit latency equals replay time.- Steady-state overhead: one walsender per standby (a few % CPU), WAL volume unchanged (replication carries the same bytes the primary wrote anyway).
-- the state, live
SELECT pid, state, sync_state, replay_lag
FROM pg_stat_replication;
pid | state | sync_state | replay_lag
--------+-----------+------------+------------
41203 | streaming | sync | 00:00:00.032replay_lag: 32ms — 32 milliseconds of data the standby doesn't have yet. Every high-availability decision in Postgres is a conversation about that number: how big may it grow (async), whether commits wait for it (sync), and whether the app can survive a failover into its gap (it's the size of your loss window).