Scaling Databases & Data Consistency · Scaling Reads

Read Replicas and Replication Lag

Offload reads to replicas — and handle the stale data, lag spikes and failovers that come with them.

Scenario

Example app: a marketplace sends all GET requests to two read replicas to take load off the primary. Replication is asynchronous; lag is usually under 100 ms.

What you see in production

  • Sellers edit a listing, click Save, land on the listing page — and see the old price. Refreshing a second later fixes it.
  • During a nightly batch import, lag reaches 40 seconds; "my order disappeared" tickets spike.
  • Once a replica fell hours behind after a disk issue, and reads silently served day-old data.

The naive code

Route by HTTP method, ignore lag:

class PrimaryReplicaRouter:
    def db_for_read(self, model, **hints):
        return random.choice(["replica1", "replica2"])     # every read, regardless of lag
    def db_for_write(self, model, **hints):
        return "default"

Reproduce it

A primary and a replica that applies changes 300 ms later:

Save on the primary, read from a lagging replicaTry it yourself
primary, replica, replication_queue = {"price": 100}, {"price": 100}, []
LAG_MS = 300

def write(key, value, now):
    primary[key] = value
    replication_queue.append((now + LAG_MS, key, value))

def replicate(now):
    for item in [i for i in replication_queue if i[0] <= now]:
        replica[item[1]] = item[2]
        replication_queue.remove(item)

write("price", 89, now=0)                  # seller saves
for t in (50, 200, 350):
    replicate(t)
    print(f"t={t:3} ms  primary={primary['price']}  replica={replica['price']}"
          f"{'  <- stale read' if replica['price'] != primary['price'] else ''}")

Output

t= 50 ms  primary=89  replica=100  <- stale read
t=200 ms  primary=89  replica=100  <- stale read
t=350 ms  primary=89  replica=89

Root cause

Asynchronous replication ships changes after commit; replicas are always slightly behind, and lag grows with write bursts, long-running queries on the replica (which can delay replay), network issues and replica restarts. Any read that must reflect a recent write — the user's own, or a business decision — can be wrong on a replica.

A real-world reminder that replication is an operational system, not just a setting: GitLab's January 2017 outage began with a PostgreSQL secondary falling behind under load and needing a manual re-sync; during that work, data was accidentally removed from the primary and roughly six hours of data were lost (GitLab postmortem).

The fix

  1. Decide per query whether staleness is acceptable: catalog browsing yes; "my orders", balances, checkout no.
  2. Read-your-writes for the writer's own data (next lesson).
  3. Watch lag and stop using replicas that are too far behind:
-- on the primary: lag per replica
SELECT application_name, replay_lag, pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS bytes_behind
FROM pg_stat_replication;
-- on a replica: time since the last replayed transaction
SELECT now() - pg_last_xact_replay_timestamp() AS lag;

Trade-offs

  • Replicas scale reads, not writes; every replica replays every write.
  • Falling back to the primary when replicas lag protects correctness but can overload the primary — cap it and shed non-essential reads.
  • Synchronous replication removes lag for committed data but adds write latency and availability risk; most systems use it only for a failover standby.
  • Long queries on a Postgres replica can be cancelled by replay conflicts (or delay replay with hot_standby_feedback, which bloats the primary) — run analytics on a dedicated replica.

Detect it in production

  • Graph replica lag per replica; alert at your staleness budget (e.g. > 5 s) and on replication stopping.
  • Track the share of reads served by the primary vs replicas.
  • Tickets like "saved but not showing" cluster around lag spikes — correlate them.

Key points

  • Async replicas are always behind; lag spikes under write load.
  • Classify reads by freshness need; route critical ones to the primary or use read-your-writes.
  • Monitor lag and drain lagging replicas automatically.

Practice questions

A seller saves a new price and immediately sees the old one. Reads go to a replica. What's happening?

Answer

Asynchronous replication: the replica hasn't replayed the write yet. The read after the write must go to the primary (or wait until the replica has caught up) for read-your-writes.

How do you measure replication lag in Postgres and MySQL?

Answer

Postgres: pg_stat_replication on the primary (replay_lag, LSN difference) or now() - pg_last_xact_replay_timestamp() on the replica. MySQL: SHOW REPLICA STATUS (Seconds_Behind_Source).

Why don't read replicas help a write-heavy workload?

Answer

Every replica must apply every write, and writes still go to one primary. Replicas only offload reads; scaling writes needs batching, partitioning or sharding.

Open in the app to mark lessons complete, take notes and track your progress.