Scaling Reads with Redis Replica Nodes

Your primary Redis node is saturating a single CPU core on GET-heavy traffic long before it runs out of memory, and vertical scaling has stopped paying off. The fix is to keep every write on the primary and fan reads out across one or more read-only replicas, each of which streams a live copy of the keyspace from the primary. The cost you take on in exchange is replication lag: a replica applies the primary's changes a few milliseconds to a few seconds behind, so a read served from it can be stale. This page shows how to attach replicas, route reads to them from redis.asyncio, preserve read-your-writes consistency where it matters, and quantify the lag so staleness stays inside a budget you have chosen deliberately. It builds on replication and read scaling and assumes the read path itself follows the cache-aside pattern.

Prerequisites

  • Redis 7.x on the primary and every replica host (Redis 5+ for the REPLICAOF verb; older builds use SLAVEOF).
  • redis-py 5.x on Python 3.10+ (pip install "redis>=5,<6"), using the redis.asyncio client throughout.
  • Network reachability from each replica to the primary on the Redis port, and from application hosts to both.
  • Permission to run INFO replication, CONFIG GET, and REPLICAOF on the nodes you manage.
  • A read-heavy key family whose staleness tolerance you can state in wall-clock terms — the maximum age a served value may have.

Step-by-Step Implementation

1. Attach a replica to the primary

Point each replica host at the primary so it opens a replication link and begins applying the write stream. Persist the directive in redis.conf so the role survives a restart rather than reverting to an independent primary.

# Runtime attach (Redis 5+); use SLAVEOF on 4.x and earlier.
redis-cli -h replica-1 REPLICAOF redis-primary 6379

# Durable equivalent in redis.conf on every replica:
#   replicaof redis-primary 6379
#   replica-read-only yes        # reject writes sent to the replica

2. Confirm replication with INFO replication

Verify the link is up on both sides before you route any traffic. The primary should report each replica as online, and the replica should show master_link_status:up.

redis-cli -h redis-primary INFO replication
# role:master, connected_slaves:3
# slave0:ip=...,state=online,offset=51230,lag=0

redis-cli -h replica-1 INFO replication
# role:slave, master_link_status:up, slave_read_only:1

3. Route reads to replicas

In Redis Cluster, let the client send reads to a replica of the shard that owns each key by enabling read_from_replicas. This keeps routing transparent while MOVED/ASK handling stays in the client.

import redis.asyncio as redis

# Cluster client: GET/MGET are dispatched to a replica of the owning shard.
cluster = redis.RedisCluster(
    host="redis-node-1",
    port=6379,
    read_from_replicas=True,   # reads fan out to replicas, writes stay on primaries
    decode_responses=True,
)

When you manage the topology yourself, open a dedicated connection to a replica node and issue READONLY so the connection accepts reads served from replica state instead of redirecting them to the primary.

# Alternative: talk directly to one replica and opt the connection into replica reads.
replica = redis.Redis(host="redis-node-4", port=6379, decode_responses=True)

async def replica_get(key: str) -> str | None:
    await replica.execute_command("READONLY")  # accept reads on this cluster replica
    return await replica.get(key)

4. Keep every write on the primary

Replicas are read-only, so all mutations must target the primary. Keep the two roles in separate client objects — pairing this split with connection pooling and client resilience stops a read burst from starving the write pool.

primary = redis.Redis(host="redis-primary", port=6379, decode_responses=True)

async def write_key(key: str, value: str, ttl: int = 300) -> None:
    # Mutations never go to a replica; replica-read-only rejects them anyway.
    await primary.set(key, value, ex=ttl)

5. Handle read-your-writes with primary reads for fresh keys

A user who just wrote a value and immediately reads it back can hit a replica that has not yet applied the change. Route reads of recently written keys to the primary for a short window so the writer never sees their own stale data.

RECENT_TTL = 3  # seconds a key is considered "just written"

async def write_and_mark(key: str, value: str) -> None:
    await primary.set(key, value, ex=300)
    await primary.set(f"recent:{key}", 1, ex=RECENT_TTL)  # short-lived freshness flag

async def read_your_writes(key: str) -> str | None:
    if await primary.exists(f"recent:{key}"):
        return await primary.get(key)   # bypass replica lag for a freshly written key
    return await replica.get(key)       # everything else is served from a replica

6. Measure replication lag in bytes

Lag is the gap between the primary's write offset and the offset a replica has applied. Compute it directly from master_repl_offset on the primary minus slave_repl_offset on the replica, and alert when it crosses your staleness budget.

# Lag in bytes = primary's committed offset - this replica's applied offset.
MASTER=$(redis-cli -h redis-primary INFO replication | awk -F: '/master_repl_offset/{print $2+0}')
REPLICA=$(redis-cli -h replica-1 INFO replication | awk -F: '/slave_repl_offset/{print $2+0}')
echo "lag_bytes=$((MASTER - REPLICA))"   # sustained growth means the replica is falling behind

7. Cap the replica count and chain deeper fan-out

Every direct replica adds a replication stream the primary must serialize and ship, so the primary's write throughput bounds how many replicas it can feed. Keep direct replicas modest — usually five or fewer — and chain additional ones off an existing replica when you need broader read capacity.

redis-cli -h redis-primary INFO replication | grep connected_slaves   # watch the fan-out

# Deeper capacity without loading the primary: make a replica a sub-primary.
redis-cli -h replica-1  REPLICAOF redis-primary 6379   # tier 1, streams from primary
redis-cli -h replica-1a REPLICAOF replica-1     6379   # tier 2, streams from tier 1
Writes to one primary, reads fanned across three lagging replicas An application box on the left sends a single write arrow up to a primary node carrying master offset 51230. The primary streams replication to three read-only replicas on the right: replica one in sync at offset 51230, replica two trailing by 42 bytes, replica three trailing by 440 bytes. From the same application, three read arrows fan out to the replicas, spreading read load while writes remain on the primary. WRITES → ONE PRIMARY · READS → MANY REPLICAS Application write pool + read pool Primary accepts all writes master_repl_offset 51230 writes Replica 1 offset 51230 in sync · lag 0 B Replica 2 offset 51188 lag 42 B Replica 3 offset 50790 lag 440 B replication stream reads fanned to replicas

Failure Modes

Stale reads from a lagging replica. A replica falls behind under a write burst or a slow network, so a read served from it returns a value older than your staleness budget. Diagnose by watching lag climb without recovering:

redis-cli -h replica-1 INFO replication | grep -E "slave_repl_offset|master_link_status"

Fix by routing recently written keys to the primary (Step 5) and by refusing to route reads to any replica whose lag exceeds a byte threshold — drop it from the read pool until it catches up.

Replica serving during a full resync. After a link drop the replica may need a full PSYNC, during which master_link_status reads down. With replica-serve-stale-data yes (the default) it keeps answering with pre-resync data; with no it returns errors instead. Diagnose:

redis-cli -h replica-1 INFO replication | grep master_link_status   # want: up
redis-cli -h replica-1 INFO stats | grep sync_full                  # rising = repeated full resyncs

Fix by sizing repl-backlog-size so brief disconnects use a partial resync, and by removing a resyncing replica from rotation until its link is up.

Reads silently collapsing onto the primary. If every replica is down or read_from_replicas is misconfigured, the client quietly sends all reads to the primary, erasing the scaling you built. Diagnose by confirming the primary still sees replicas:

redis-cli -h redis-primary INFO replication | grep connected_slaves   # 0 means no replicas attached

Fix by alerting on connected_slaves dropping below your expected count and by verifying the read path in a canary that asserts which node served a sampled read.

Verification

Confirm a replica actually rejects writes, so a misrouted mutation fails loudly instead of silently diverging:

redis-cli -h replica-1 SET probe 1
# expect: (error) READONLY You can't write against a read only replica.

Confirm reads are landing on replicas rather than the primary by comparing per-command counts across nodes before and after a load test:

redis-cli -h replica-1 INFO commandstats | grep cmdstat_get   # calls should climb here
redis-cli -h redis-primary INFO commandstats | grep cmdstat_get  # should stay near flat

Assert lag stays inside budget across every replica during peak write load:

for r in replica-1 replica-2 replica-3; do
  off=$(redis-cli -h "$r" INFO replication | awk -F: '/slave_repl_offset/{print $2+0}')
  echo "$r applied_offset=$off"   # compare each against master_repl_offset
done

FAQ

How many replicas can one primary feed?

The primary serializes and ships a separate replication stream to each direct replica, so its write throughput and network bandwidth cap the count — five or fewer direct replicas is a common ceiling. When you need more read capacity, chain replicas into a tree by making one replica the source for others (Step 7) so the primary only ever feeds its first tier.

Will reads from replicas ever be stale?

Yes. A replica applies the primary's writes asynchronously, so between a write committing on the primary and that write reaching a replica there is a window — usually milliseconds, longer under load — in which the replica returns the old value. Replica reads trade strict freshness for throughput, which is why you route reads there only for data whose staleness tolerance you have measured.

How do I guarantee a user sees their own writes?

Mark each just-written key with a short-lived flag on the primary and, while that flag exists, serve reads of the key from the primary instead of a replica (Step 5). This bounds the extra primary load to a few seconds per write while giving the writer read-your-writes consistency; every other reader still benefits from replica fan-out.

What happens to reads when a replica is promoted during failover?

If the primary fails, a replica is promoted to primary and begins accepting writes; the promotion and its coordination are the subject of Redis Sentinel vs Cluster Failover. A client using read_from_replicas continues sending reads to the remaining replicas, and the newly promoted node takes writes. Point your write client at the discovered primary — via Sentinel or cluster topology — rather than a hard-coded host so it follows the promotion automatically.

When should I use the WAIT command instead of replica reads?

WAIT numreplicas timeout blocks a write until it has been acknowledged by that many replicas, giving you a stronger durability guarantee on the write path. It does not make replica reads fresh — it is a write-side tool, not a read-side one. Use WAIT when losing an acknowledged write during failover is unacceptable; use primary reads for keys that need read-your-writes. The two solve different problems and can be combined.


Up: Replication and Read Scaling with Redis Replicas