· 7 min read
Replication lag: read replicas and read-your-writes consistency
Why read replicas return stale data after a write, how replication lag works, and three ways to get read-your-writes consistency.

Replication lag is the delay between a write committing on the primary database and that write becoming visible on a read replica. If your app writes to the primary and then reads from a replica, the read can return data from before the write, so a user saves their profile and the page shows the old version. The usual fix is read-your-writes consistency: route a user's reads to the primary, or to a replica that has provably caught up, for a short window after they write.
Read replicas are one of the first tools teams reach for when a database gets busy. They are cheap to add and they take read traffic off the primary. The cost is that every replica is slightly behind, and the code that sends queries to them has to know that.
What is replication lag?
Most read replicas use asynchronous replication: the primary commits a transaction, tells the client it succeeded, and ships the change to replicas afterwards. The primary does not wait for any replica to confirm. That keeps writes fast, and it means there is always a gap between "committed" and "visible on the replica".
In PostgreSQL, the change travels as WAL (write-ahead log), the ordered record of every change the database makes. Each position in the WAL has an LSN (log sequence number), a monotonically increasing address like 0/3017538. A replica receives WAL from the primary and replays it. Lag is simply how far the replica's replay position trails the primary's current position.
On a quiet system lag is often measured in milliseconds. It grows when:
- the primary takes a burst of writes, such as a bulk import or a migration,
- the replica is busy with heavy read queries and replays slowly,
- the network between them is congested or crosses regions,
- a long-running query on the replica conflicts with replay and holds it back.
The important point is that lag has no upper bound you can rely on. Code that assumes "replicas are usually 50 ms behind" will break on the day they are 30 seconds behind.
Why do users see stale data after a write?
The classic sequence looks like this:
- The user submits a form. The app runs an
UPDATEon the primary, which commits. - The app redirects to the profile page.
- The profile page runs a
SELECT, and the read router sends it to a replica. - The replica has not replayed the update yet, so it returns the old row.
Nothing is broken in the database. Each component did what it was designed to do. The user, though, sees their change disappear, often clicks save again, and sometimes creates a duplicate.
You can reproduce this locally with PostgreSQL by pausing replay on a replica. With replay paused, insert a row on the primary and count rows on the replica: the replica still returns the old count. Resume replay and the new row appears. The same thing happens in production, just for a much shorter and less predictable time.
How do you get read-your-writes consistency?
Read-your-writes (also called read-after-write consistency) is the guarantee that once a user's write succeeds, that same user's later reads will include it. It says nothing about other users, which is what makes it cheap enough to provide. There are three common ways to do it.
Pin to the primary after a write
The simplest approach: when a user writes, record a timestamp in their session or a cookie. For the next few seconds, send all of that user's reads to the primary.
const PIN_MS = 5000;
function pickPool(session) {
const pinned = session.lastWriteAt && Date.now() - session.lastWriteAt < PIN_MS;
return pinned ? primary : replicas.next();
}
async function updateProfile(session, data) {
await primary.query("UPDATE users SET name = $1 WHERE id = $2", [data.name, session.userId]);
session.lastWriteAt = Date.now();
}
This is easy to build and easy to reason about. Its weakness is the fixed window. If lag exceeds the window, stale reads come back. Pick a window well above your normal lag and alert when lag approaches it.
Wait for the replica to reach the write's LSN
A more precise approach tracks the position of the write itself. After the write commits, read the primary's current WAL position and store it with the session. Before reading from a replica, check whether that replica has replayed at least that far.
-- On the primary, in a separate statement after the write commits
SELECT pg_current_wal_lsn(); -- e.g. 0/3017538
-- On the replica
SELECT pg_last_wal_replay_lsn() >= '0/3017538'::pg_lsn AS caught_up;
If caught_up is true, the replica is safe for this user. If not, either wait briefly and check again, try another replica, or fall back to the primary. One detail worth getting right: capture the LSN after the transaction has committed, not inside it. A position read inside the transaction can be earlier than the commit record, and then a replica can report it has caught up when the write is still missing.
This approach keeps more reads on replicas, because a user is only routed to the primary while the specific replica they would use is behind. MySQL offers a similar mechanism built on GTIDs (global transaction identifiers), where a replica can be asked to wait until it has applied a given set of transactions.
Route by data, not by user
Some reads never need to be fresh: analytics pages, public listings, search results, background reports. Others always do: a checkout total, a permission check, a balance. You can make the routing decision per query type instead of per session, sending the fresh-critical reads to the primary every time. This works well alongside either of the approaches above.
| Approach | Precision | Primary load | Complexity |
|---|---|---|---|
| Always read from primary | Exact | Highest | None |
| Pin user to primary for N seconds | Good, fails if lag > N | Moderate | Low |
| Wait for replica to reach write LSN | Exact for the user's own writes | Low | Medium |
| Route fresh-critical queries to primary | Exact for those queries | Depends on mix | Low |
How do you measure replication lag?
You can't manage lag you don't watch. On a PostgreSQL primary, the pg_stat_replication view shows each connected replica with its replay position and a replay_lag interval. On a replica, compare its replay position with the primary's, or check the timestamp of the last replayed transaction:
-- On the primary: lag per replica
SELECT application_name, replay_lsn, replay_lag FROM pg_stat_replication;
-- On a replica: bytes behind a known primary LSN
SELECT pg_wal_lsn_diff('0/3017538', pg_last_wal_replay_lsn()) AS bytes_behind;
-- On a replica: when the last replayed transaction committed on the primary
SELECT now() - pg_last_xact_replay_timestamp() AS approx_lag;
The timestamp method has a known quirk: on an idle primary there is nothing new to replay, so the age keeps growing even though the replica is fully caught up. Pair it with the LSN comparison before alerting.
Useful habits once you have the numbers:
- Alert on lag well before it reaches your pinning window.
- Have the read router take a replica out of rotation when its lag crosses a threshold, and put it back when it recovers.
- Watch lag during deploys and migrations, which are the usual sources of write bursts.
Read replicas are a good way to absorb read-heavy traffic, and they pair naturally with designing for the spike rather than the average. They only help if the code in front of them treats freshness as something to route on, not something to assume.
When should you avoid read replicas?
Replicas are a poor fit when nearly every read must reflect the latest write, such as a workflow where each step reads what the previous step just wrote. In that case most reads end up on the primary anyway, and you have paid for replicas without moving much load. They also do nothing for write throughput, because every replica still replays every write. If the primary is struggling with writes, look at batching, query tuning or partitioning the data instead.
Synchronous replication, where the primary waits for a replica to confirm before reporting success, can close the gap. PostgreSQL can be configured to wait until a standby has applied the change. The price is higher write latency and a primary that stalls when the synchronous replica is slow or unavailable, so it is usually reserved for durability needs rather than used to fix stale reads.
Key takeaways
- Asynchronous read replicas are always somewhat behind, and the lag has no reliable upper bound.
- Stale reads after a write are expected behavior, not a database bug.
- Read-your-writes consistency only has to hold for the user who wrote, which makes it cheap.
- Pinning to the primary for a few seconds is simple; comparing LSNs is precise.
- Monitor lag and pull replicas out of rotation when they fall too far behind.
FAQ
Does adding more read replicas reduce replication lag?
No. Each replica replays the full stream of writes on its own, so adding replicas spreads read load but does not make any single replica catch up faster.
Is replication lag the same as eventual consistency?
Replication lag is the reason asynchronous replicas are eventually consistent. Given no new writes, every replica converges to the primary's state, but at any moment a replica may be behind.
How long should I pin a user to the primary after a write?
Long enough to cover your normal worst-case lag with margin, often a few seconds. Measure your real lag first, and alert if it approaches the window you chose.