Read Replicas and Consistency

BackendDatabasesArchitecture
Share on LinkedIn

Adding read replicas fixed our connection pool exhaustion, then broke the profile page — users updated their name, refreshed, saw the old name for three seconds. Replicas scale reads; they don't copy the consistency model of a single node unless you engineer it.

Primary-replica topology

        ┌──▶ Replica 1 (reads)
App ──▶ Primary (writes)
        └──▶ Replica 2 (reads)

Primary accepts writes, streams WAL/binlog to replicas. Replicas apply changes in order — usually asynchronous.

Consistency spectrum

Mode Behavior
Strong (single node) Reads see latest commit
Async replica Reads may be stale
Semi-sync At least one replica acked before commit ack — reduces loss risk, not read staleness
Sync quorum Commit after N replicas — costly, rare in general OLTP

Replication lag measured in bytes, seconds, or transactions behind:

-- PostgreSQL
SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes
FROM pg_stat_replication;

Alert on lag p99, not just average.

Staleness budgets

Define acceptable lag per use case:

Query Staleness OK?
Dashboard analytics Minutes
Product catalog Seconds
User profile after edit Zero
Payment status after checkout Zero

Route accordingly — not all reads go to replicas.

Read-your-writes patterns

Sticky primary after write — session flag routes reads to primary for N seconds post-write:

def get_user(user_id, session):
    if session.recently_wrote_user(user_id):
        return db_primary.get_user(user_id)
    return db_replica.get_user(user_id)

Monotonic reads — same user always hits same replica via consistent hashing to avoid flip-flopping between lag states.

Wait for catch-up — Postgres pg_wait_for_lsn or proxy logic:

SELECT pg_wait_for_lsn('0/ABC123');  -- after recording write LSN

Adds latency; use sparingly on critical paths.

Causal consistency tokens — client sends last seen LSN; proxy routes or waits until replica >= LSN.

Proxy and driver support

PgBouncer, RDS Proxy, HAProxy, Vitess, Citus — some offer read/write split with lag awareness. ORMs often need explicit using(:replica) vs using(:primary) — no magic default.

Aurora reader endpoints load-balance replicas; Aurora MySQL global database offers cross-region with defined RPO/RTO tradeoffs.

When replicas mislead

Never enforce uniqueness or financial invariants on replica reads alone.

Failover and consistency

Promote lagging replica → data loss window equals lag at promotion time. Use pg_replication_slot monitoring, synchronous standbys for zero RPO tiers (write latency cost).

Test failover quarterly — replica promotion changes DNS/connection strings; apps must reconnect.

Scaling replicas without lies

Add replicas until lag stable under peak write load. If lag unbounded, primary write throughput exceeds apply capacity — shard or upgrade replica hardware.

Connection pool per replica; don't fan one pool to all nodes blindly.

Cache hot reads (Redis) with TTL + invalidation on write — reduces replica load and staleness sensitivity for read-heavy keys.

Aurora and managed database specifics

Managed databases offer replica features beyond vanilla Postgres:

Amazon Aurora:

Google Cloud SQL:

Supabase/Neon:

Managed offerings reduce ops burden but don't eliminate staleness — application routing logic still required.

Read replica for analytics

The cleanest pattern: never mix OLTP and analytics queries on the same replica pool.

Primary → OLTP reads requiring freshness
Replica pool A → Application reads tolerating seconds of lag
Replica pool B → Analytics/BI queries (can tolerate minutes)

Analytics queries (full table scans, complex joins) starve OLTP reads if they share replica pools. Dedicated analytics replica — or better, replicate to warehouse via CDC — keeps OLTP performance stable.

Connection pool routing

class DatabaseRouter:
    def __init__(self, primary_pool, replica_pool):
        self.primary = primary_pool
        self.replica = replica_pool

    def read(self, session, query):
        if session.needs_fresh_read:
            return self.primary.execute(query)
        return self.replica.execute(query)

    def write(self, query):
        result = self.primary.execute(query)
        session.mark_written()  # subsequent reads go to primary
        return result

ORM integration (Django database router, Rails multiple databases, TypeORM replication) provides similar patterns — configure explicitly, don't assume automatic routing.

Failure modes

Production checklist

Common production mistakes

Teams get read replicas consistency wrong in predictable ways:

Data pipelines for read replicas consistency silently corrupt when schema evolution is backward-incompatible, late-arriving events are dropped, and warehouse costs spike because nobody partitions by query pattern.

Resources

Frequently asked questions

What is a read replica?

A read replica is a copy of a primary database that applies changes asynchronously or semi-synchronously from the primary's write-ahead log or binlog. Applications route read queries to replicas to offload the primary, accepting potential replication lag between write on primary and visibility on replica.

What is replication lag?

Replication lag is the delay between a commit on the primary and when that change appears on a replica. Lag spikes during heavy writes, network issues, or slow replica apply. Users reading from lagging replicas see stale data — missing recent rows or outdated values.

How do I implement read-your-writes consistency?

Route reads for a session to the primary after a write, use session tracking of last write LSN and wait for replica catch-up, or employ globally consistent read endpoints (Aurora global DB, Spanner). Simplest pattern: writes and immediate subsequent reads hit primary; background analytics hit replicas.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →