Replication Internals: WAL Shipping, LSNs, Lag and Failover
A replica is a machine that consumes the primary's change log and re-applies it; every property of replication — what a replica can serve, how far behind it is, what a failover loses, whether two primaries can exist — is a statement about positions in that log.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
One machine's disk can die, and one machine's CPU can serve only so many reads. You need a second copy of the data that is current enough to serve reads and complete enough to take over.
↓ - Naive solution
Copy the data files to another machine every night; point read traffic at the copy; restore from it if the primary dies.
↓ - Why it breaks
The copy is up to a day stale, so reads lie and a failover loses a day of writes; and copying files under a running database yields a torn, inconsistent snapshot unless you stop the world.
↓ - Better idea
The primary already writes a sequential, ordered description of every change before it touches a page — the WAL. Ship that stream continuously and have the replica replay it; the replica is then a crash-recovering copy that never finishes recovering.
↓ - Internal mechanism
Primary → WAL record at LSN n → sender process → network → receiver on the replica writes it, fsyncs, replays it into its own pages. Lag is the difference between the primary's LSN and the replica's replay LSN. The commit acknowledgement can be released after the local fsync (async), after one replica confirms (semi-sync), or after all listed replicas confirm (sync).
↓ - Trade-offs
Async: fast commits, stale replica reads, lost tail on failover. Sync: no lost writes, every commit waits on the slowest standby, and a dead standby blocks all writes. Physical streams are byte-identical and cannot cross versions or filter tables; logical streams decode row changes and can, at the price of decoding cost and no DDL.
↓ - Real database
PostgreSQL streaming replication with
pg_stat_replication, slots andpg_rewind; MySQL binlog replication with GTIDs; Patroni and Orchestrator for automated promotion.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
The primary appends every change to its write-ahead log and sends the same bytes to each replica as they are written. The replica applies them to its own copy of the pages, so it converges on the primary's state a little behind. Reads can go to the replica; writes cannot.
How far behind is measured as a difference between log positions — the LSN the primary has written minus the LSN the replica has applied — and can be converted into seconds. Whether the primary waits for the replica before telling the client "committed" is the synchronous/asynchronous choice.
Primary → change log → stream → replica → apply
The practical lesson said "the primary streams its changes"; this is what is being streamed. Every engine with a write-ahead log already produces an ordered, durable, self-describing sequence of changes before it modifies any page (Write-Ahead Logging). Replication is that sequence with a network in the middle: a sender on the primary reads records as they are flushed and pushes them; a receiver on the replica writes them to its own log, fsyncs, and reports back how far it got; an applier replays them into the replica's pages exactly as crash recovery would (Crash Recovery). A replica is a database that is permanently in recovery, and that is not a metaphor — it is the same code path.
Because the stream is ordered, every position in it has a name. In PostgreSQL that is the LSN, a 64-bit byte offset into the WAL; in MySQL a binlog file and position, or the GTID of the last executed transaction. Everything that follows — lag, acknowledgement, failover safety, divergence — is a comparison of two such positions.
Physical vs logical
Physical (WAL shipping / streaming) sends the log records themselves — "on block 812 of relation 16412, replace bytes 4080–4136 with these". The replica applies them to identical pages and becomes a byte-for-byte copy: same indexes, same bloat, same free-space map, same catalog. That makes it cheap (no decoding, no SQL execution, apply is sequential page writes) and complete (DDL, sequences, everything), but rigid: same major version, same architecture, whole cluster or nothing, and the replica is read-only because any local write would corrupt the byte identity.
Logical decodes the same WAL into row changes — INSERT INTO orders (id, total) VALUES (…), UPDATE … WHERE id = … with old and new tuples — and sends those. The subscriber applies them as ordinary transactions, so it can be a newer version (zero-downtime upgrade), hold extra indexes or tables, receive only some tables, or be a data warehouse, a Kafka topic or a search index (change-data-capture). The price: decoding costs CPU on the primary, apply is single-threaded per subscription unless parallelised, DDL is not replicated, tables need a replica identity (a PK) for UPDATE and DELETE, and sequences and large objects are excluded. Logical decoding also needs the primary to keep WAL until the consumer confirms it — a replication slot — which is the origin of most "disk full" incidents involving replication.
| Physical (WAL streaming) | Logical (decoded row changes) | |
|---|---|---|
| What is sent | WAL records: page-level byte changes | Row events with old/new values |
| Replica is | Byte-identical, read-only | An independent database applying writes |
| Version / schema | Same major version, same schema | Different version, subset, extra indexes, other system |
| DDL, sequences | Replicated | Not replicated (DDL), sequences excluded |
| Apply cost | Sequential page writes, cheap | SQL execution per row, needs a PK for UPDATE/DELETE |
| Failover target | Yes — promote it | Usually no: it is not a copy |
| Typical use | HA + read scaling | Upgrades, CDC, selective replication, multi-master |
Synchronous, asynchronous, semi-synchronous: where the ack sits
The mode names *which position in the pipeline COMMIT waits for* before replying. Asynchronous: the primary fsyncs its own WAL and replies — 1–3 ms — and the record streams afterwards; a replica may be anywhere behind, and if the primary dies the un-streamed tail is gone. Synchronous: the primary sends the record, waits for each listed standby to report it has fsynced (remote_flush) or applied (remote_apply) it, then replies. Every commit now costs the slowest standby's round trip plus its fsync; within a datacenter ~1–2 ms extra, across regions 50–150 ms. And if a synchronous standby stops responding, every commit blocks: synchronous replication converts a replica failure into a primary outage unless a quorum form is used. Semi-synchronous (ANY 1 (a, b) in PostgreSQL; rpl_semi_sync in MySQL) waits for *any one* of several standbys — durable on two machines, fastest of the candidates, tolerant of one standby dying.
The choice is per transaction, not per cluster: SET LOCAL synchronous_commit = off for an audit-log insert that can be replayed, remote_apply for the payment whose confirmation page will be read from a replica. Remote apply deserves attention: it is the only mode under which a replica listed as synchronous can never serve a stale read of an acknowledged write, and it is the most expensive because it waits for replay, not just for the fsync.
t=0.0 ms client: COMMIT t=0.1 primary appends commit record at LSN 0/1A2B448 to WAL buffers t=2.0 primary fsync of WAL ────────────────────────────────── ASYNC: reply "committed" here t=2.5 walsender pushes 0/1A2B448 to replicas A and B t=3.5 A: received, written, fsynced → reports flush_lsn 0/1A2B448 ── SEMI-SYNC (ANY 1): reply here t=4.0 A: replayed → replay_lsn 0/1A2B448 t=9.0 B (slower link): flush_lsn 0/1A2B448 ─────────────────── SYNC (FIRST 2 / remote_flush): reply here t=10.5 B: replay_lsn 0/1A2B448 ─────────────────────────────── SYNC remote_apply: reply here if B stops responding: ASYNC and SEMI-SYNC unaffected; SYNC: every COMMIT on the primary waits forever
Lag is a difference of LSNs; stale reads are a difference of LSNs
A replica is at three positions at once: what it has received, what it has flushed to its own log, and what it has replayed into pages. Reads see only the third. Lag in bytes is primary_current_lsn − replay_lsn; lag in time is how long ago the primary wrote the record the replica is currently replaying (replay_lag in pg_stat_replication, computed from timestamps the primary embeds). Bytes tell you how much work remains; seconds tell you how stale a read is. Both are needed: a replica 2 MB behind on an idle primary may be seconds stale; one 2 MB behind on a busy primary may be 50 ms stale.
A stale read is a read served from a replay position below the LSN of the write the client was acknowledged for. The fixes in Replication and Read Scaling are all LSN comparisons: after a write, remember pg_current_wal_lsn(); before reading from a replica, check pg_last_wal_replay_lsn() >= that and wait or fall back to the primary. Lag has causes worth distinguishing: network (bytes per second), apply (the replica's single replay process cannot keep up — a large index build or a bulk update on the primary), and conflict (a long query on the replica holds pages that replay needs to change; PostgreSQL either waits max_standby_streaming_delay or cancels the query — the "canceling statement due to conflict with recovery" error, and the reason hot_standby_feedback exists, which in turn holds back VACUUM on the primary).
1-- on the primary: every connected replica and its three positions2SELECT application_name, state, sync_state,3 pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn) AS send_lag_bytes,4 pg_wal_lsn_diff(pg_current_wal_lsn(), flush_lsn) AS flush_lag_bytes,5 pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag_bytes,6 write_lag, flush_lag, replay_lag7FROM pg_stat_replication;8 9-- on the replica: how stale am I?10SELECT pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn(),11 now() - pg_last_xact_replay_timestamp() AS staleness;12 13-- read-your-writes: the app remembers the LSN of its write …14SELECT pg_current_wal_lsn(); -- on the primary, after COMMIT → '0/1A2B448'15-- … and only reads from a replica that has replayed it16SELECT pg_last_wal_replay_lsn() >= '0/1A2B448'::pg_lsn; -- on the replicaFailover: the promotion sequence and split brain
Promotion is a sequence, and every step has a failure mode. Detect that the primary is gone — with a timeout, which means "unreachable for 10 s", not "dead". Fence the old primary so it cannot keep accepting writes: power it off, revoke its storage, or rely on a lease it must renew through the majority. Choose the replica with the highest flushed LSN — promoting any other loses writes that replica had. Promote: the replica finishes replaying what it has, opens a new timeline (PostgreSQL writes a history file; MySQL starts issuing GTIDs under its own server UUID), and accepts writes. Re-point every other replica and the application (DNS, a proxy, a virtual IP, a connection string in a config store). Rejoin the old primary as a replica when it comes back — which requires discarding the WAL it wrote after the divergence point (pg_rewind finds it by comparing timeline histories; MySQL by GTID set difference) before it can follow the new leader.
What is lost: under async replication, every acknowledged write the old primary had not yet streamed to the promoted replica. Under sync or semi-sync to the promoted node, nothing. Split brain is the failure of step two: the old primary was partitioned rather than dead, kept accepting writes from clients that could still reach it, and now two histories exist that both contain acknowledged commits. There is no merge for byte-level WAL; one side is discarded. Every serious HA manager (Patroni, Stolon, Orchestrator, cloud managed services) runs leader election through a consensus store precisely so that a node can only be primary while it holds a majority-granted lease — the mechanism Distributed Fundamentals: Partial Failure, Quorums, Consensus, Recovery derives.
timeline 1 (old primary A): … ─ LSN 100 ─ 101 ─ 102 ─ 103 ─ 104 ─ 105 (103–105 never reached B: async)
│
└─ B last flushed 102 → promoted
timeline 2 (new primary B): 102 ─ 103' ─ 104' ─ 105' ─ 106' …
lost: A's 103, 104, 105 — acknowledged to clients, present nowhere else
diverged: if A comes back and is not fenced, A's 103–105 vs B's 103'–105' — two acknowledged histories
rejoin: pg_rewind A to LSN 102 (discard 103–105), then A streams timeline 2 from B
replica C: if C had flushed 104 from A, it is AHEAD of B → must also be rewound, or it cannot followReplication slots and WAL retention
The primary recycles WAL segments once a checkpoint has made them unnecessary for its own recovery — but a replica that is behind, or disconnected, still needs them. Without protection, a replica that falls behind by more than wal_keep_size finds its next segment gone and must be rebuilt from a base backup. A replication slot (pg_create_physical_replication_slot, or the slot every logical subscription creates) records the oldest LSN its consumer has confirmed (restart_lsn) and forbids recycling anything after it. Slots make replicas robust to disconnection and are mandatory for logical decoding, which must also retain the catalog state needed to decode old records.
The cost is that a slot whose consumer has vanished — a decommissioned replica, a CDC connector that crashed — pins WAL forever. pg_wal grows until the disk is full and the primary stops. max_slot_wal_keep_size (13+) caps it by invalidating the slot instead; pg_replication_slots.wal_status (reserved, extended, unreserved, lost) and safe_wal_size are the columns to alert on. Slots also carry xmin when hot_standby_feedback is on, holding back VACUUM on the primary for as long as the replica's longest query runs — a replica can bloat the primary.
1SELECT slot_name, slot_type, active, wal_status,2 pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal,3 pg_size_pretty(safe_wal_size) AS headroom4FROM pg_replication_slots;5 6-- an inactive slot with gigabytes retained is a disk-full incident in progress7SELECT pg_drop_replication_slot('old_analytics_replica');Binlog and GTIDs
MySQL replicates from the binary log, a server-level log of transactions written after the storage engine has prepared them — InnoDB's redo log and the binlog are kept consistent by an internal two-phase commit, which is why sync_binlog = 1 and innodb_flush_log_at_trx_commit = 1 are set together for a crash-safe primary. In row format (the default) each event carries the before- and after-image of a row, making it closer to PostgreSQL's logical stream than to physical WAL shipping; MySQL replicas are therefore independently writable (dangerous), can differ in schema, and apply events by executing them. A replica's I/O thread pulls events into a relay log; applier threads execute them, in parallel where the primary's write-set tracking proves the transactions did not conflict.
A GTID (server_uuid:transaction_number) names each transaction globally, and every server maintains gtid_executed, the set it has applied. Replication becomes "send me everything not in my set" (MASTER_AUTO_POSITION), replicas can be re-pointed to a new primary without knowing file offsets, and divergence after a failover is visible as a set difference: if the old primary has A:1-105 and the promoted replica A:1-102, B:1-40, the old primary's A:103-105 are the lost or errant transactions. Semi-synchronous replication waits for at least one replica to acknowledge receipt (not apply) of the binlog event before committing; rpl_semi_sync_source_wait_point = AFTER_SYNC acknowledges only after the replica has it, which closes the window in which a crash could expose a commit no replica holds.
old primary A gtid_executed = 3e11f0a2-…:1-105 promoted B gtid_executed = 3e11f0a2-…:1-102 , 7c4d9b18-…:1-40 (B's own writes since promotion) replica C gtid_executed = 3e11f0a2-…:1-104 (ahead of B: had 103–104 from A) GTID_SUBTRACT(A, B) = 3e11f0a2-…:103-105 ← errant transactions on A: acknowledged, present nowhere in the new lineage C cannot follow B until 103–104 are removed (rebuild) — the same rule as a PostgreSQL timeline
Key points
- Replication is the WAL with a network in the middle: sender → receiver (write, flush) → applier (replay). A replica is a database permanently in crash recovery.
- Physical streams are byte-identical and rigid; logical streams decode row changes and are flexible, costlier, and exclude DDL.
- The mode names where the ack sits: async after local fsync, semi-sync after any one standby flushes, sync after every listed standby — each step buys durability with latency and a new way to block.
- Lag is primary LSN minus replay LSN, in bytes and in seconds; a stale read is a read below the LSN you were acknowledged for. Read-your-writes is an LSN comparison.
- Failover: detect, fence, choose the highest LSN, promote onto a new timeline, re-point, rewind the old primary. Async loses the unshipped tail; missing fencing creates split brain.
- PostgreSQL slots pin WAL until the consumer confirms (and fill disks when it vanishes); MySQL GTID sets make positions global and divergence a set difference.
Replication stream
Lag is a difference between two LSNs — the primary's WAL end and the replica's replay position — measured in bytes or in the records between them, and converted to seconds by how long ago the primary wrote the record the replica is at. "At risk" counts acknowledged records no replica has yet received: what an async failover would lose.
When to use — and when not
- This mechanism fits when reads outnumber writes and can tolerate bounded staleness, or when a hot standby for failover is required.
- Synchronous or semi-sync to at least one standby fits when an acknowledged write must survive the loss of the primary.
- This mechanism does not scale writes: every replica applies every write. That is partitioning (Partitioning Internals: Key → Partition Function → Node).
- Synchronous replication across regions fits poorly for latency-sensitive commits; use it per transaction or with a local synchronous standby.
Failure modes
- Async failover promoting a replica that was behind: acknowledged writes lost, and a faster replica that must be rewound.
- A synchronous standby dies or hangs: every COMMIT on the primary blocks until it is removed from synchronous_standby_names.
- An orphaned replication slot retaining WAL until the primary's disk fills.
- Unfenced old primary after a partition-triggered promotion: split brain, two acknowledged histories, one discarded.
- Replay conflict: long replica queries cancelled, or hot_standby_feedback bloating the primary.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.
- NetworkingLatency and bandwidth of a link → Replication lag: a replica is never closer than one round trip behind, and WAL volume must fit the linkCross-region replication adds 50–150 ms to every synchronous commit; that is the network speaking, not the database.
- Distributed SystemsState machine replication → A replica is a deterministic state machine fed the primary's ordered log