Replication and Read Scaling with Redis Replicas
This page decides when a Redis deployment should fan reads out across replica nodes to buy throughput, and when that horizontal read capacity quietly costs you the consistency your application still assumes it has.
Redis replication is asynchronous by default: a write lands on the primary, is appended to its replication backlog, and streams to each replica a few milliseconds — sometimes seconds — later. That single fact defines the whole trade space. Routing reads to replicas multiplies the read capacity of a shard by the number of replicas attached to it, but every one of those reads may return a value that is older than what the primary already holds. The mechanics of who repopulates a key and how freshness is enforced are established in Cache-Aside vs Read-Through Patterns and TTL vs Explicit Invalidation; this page is where the read-path scaling decision is actually resolved, one step below the parent Redis High Availability & Failover Automation reference.
Architectural Trade-offs
Both approaches keep the same data in memory on the same primary. They differ entirely in where reads are served from — and therefore in what consistency a read can promise. The columns below are the axes that decide which cost your workload can absorb.
| Approach | Read consistency | Read latency / throughput | Staleness risk | Operational complexity |
|---|---|---|---|---|
| Route reads to replicas | Eventual — bounded by replication lag | Highest — throughput scales linearly with replica count, lowest latency near the reader | Real: any read can trail the primary by the current lag window | Medium — replica health, lag monitoring, and routing logic to maintain |
| Reads from primary, scale by sharding | Strong read-your-writes on every read | Bounded per shard; total throughput scales with shard count, not replicas | None from replication — reads see the latest committed write | Medium–High — resharding, slot migration, and hash-tag discipline |
| Hybrid: primary for critical reads, replicas for the rest | Strong where it is demanded, eventual elsewhere | High on the tolerant path, bounded on the strict path | Contained to explicitly tolerant reads | High — two routing rules and a per-query consistency contract |
The rightmost column is where teams underestimate the cost. Replica reads look like a free throughput multiplier — attach two replicas, triple the read capacity — but they silently convert a strongly consistent read into an eventually consistent one. When a workload was written against the primary's read-your-writes guarantee and later has its reads redirected to replicas for capacity, the staleness surfaces as intermittent, unreproducible bugs: a user updates a setting and the next page load shows the old value because the read went to a replica that had not yet applied the write.
Approach A — Route Reads to Replicas
A replica is a full, read-only copy of a primary. You attach one with REPLICAOF <primary-host> <primary-port> (or the replicaof directive in redis.conf); it performs an initial full synchronization and then applies the primary's command stream continuously. By default a replica refuses writes and serves reads to any connected client, so the throughput win is immediate: N replicas give you roughly N times the read capacity of a single primary for the same dataset, and you can place replicas close to readers to shave network latency.
In a non-clustered Sentinel-style topology, the client owns the routing decision. The pattern below keeps a dedicated redis.asyncio connection to the primary for writes and consistency-critical reads, and a round-robin pool of replica connections for everything that tolerates staleness:
import itertools
import redis.asyncio as redis
class ReadScaledClient:
"""Routes writes to the primary and tolerant reads across replicas."""
def __init__(self, primary_url: str, replica_urls: list[str]):
# One writable connection; the primary also serves read-your-writes reads.
self.primary = redis.Redis.from_url(primary_url, decode_responses=True)
# A replica per URL; each is a read-only async connection pool.
self.replicas = [
redis.Redis.from_url(url, decode_responses=True) for url in replica_urls
]
self._rr = itertools.cycle(self.replicas)
async def write(self, key: str, value: str, ttl: int = 300) -> None:
# Writes only ever touch the primary. SET ... ex= bounds staleness.
await self.primary.set(key, value, ex=ttl)
async def read_tolerant(self, key: str) -> str | None:
# Fan out across replicas; any of them may trail the primary by the lag.
replica = next(self._rr)
return await replica.get(key)
async def read_fresh(self, key: str) -> str | None:
# Consistency-critical read: go to the primary, never a replica.
return await self.primary.get(key)
In a Redis Cluster deployment the routing is built into the client rather than hand-rolled. Passing read_from_replicas=True (or the newer read_from_replicas load-balancing options) makes RedisCluster send read commands to a replica of the slot's owner while writes still go to the primary that owns the slot:
from redis.asyncio.cluster import RedisCluster, ClusterNode
cluster = RedisCluster(
startup_nodes=[
ClusterNode("redis-a", 6379),
ClusterNode("redis-b", 6379),
ClusterNode("redis-c", 6379),
],
read_from_replicas=True, # reads fan out to replicas of each slot owner
decode_responses=True,
)
# GET is routed to a replica of the slot owner; SET goes to the primary.
await cluster.set("product:{42}:price", "1999")
price = await cluster.get("product:{42}:price") # may be a replica read
Two operational rules matter. First, a replica that a client talks to directly (outside cluster routing) must issue READONLY on the connection before it will serve reads for slots it holds as a replica in cluster mode; the async cluster client does this for you, but a hand-managed replica connection does not. Second, replica reads are only safe for data whose staleness budget exceeds your worst-case replication lag. Reference data — product catalogs, rendered fragments, cache-aside miss fills for slowly changing rows — is an ideal fit. Session state a user just mutated is not, because that read expects to see the write that produced it.
Approach B — Reads from Primary, Scale by Sharding
The alternative refuses to trade away consistency at all: keep every read on the primary that owns the key, and add read capacity by adding primaries, not replicas. Because each shard's primary serves both its writes and its reads from the same authoritative copy, every read observes the latest committed write for that key — read-your-writes holds unconditionally. You scale not by copying the dataset but by partitioning it, so that no single primary carries the whole read load.
The unit of partitioning is the hash slot. Redis Cluster maps every key to one of 16,384 slots via CRC16, and each primary owns a contiguous range of slots; the full mechanics live in Redis Cluster Slot Allocation Basics. Adding a primary and moving slots to it spreads both the keyspace and its read traffic across more cores and more network interfaces, without ever exposing a stale copy. The client connects to the Redis cluster and lets slot ownership drive routing — reads and writes for a key both land on its owning primary:
from redis.asyncio.cluster import RedisCluster, ClusterNode
# read_from_replicas defaults to False: every command for a slot goes to the
# primary that owns it, so reads are always read-your-writes consistent.
cluster = RedisCluster(
startup_nodes=[ClusterNode("redis-a", 6379), ClusterNode("redis-b", 6379)],
decode_responses=True,
)
async def read_your_writes(cluster: RedisCluster, user_id: int, value: str) -> str:
# The hash tag {user_id} pins both keys to one slot, so the write and the
# immediately following read are served by the same primary — no lag window.
await cluster.set(f"user:{{{user_id}}}:cart", value)
return await cluster.get(f"user:{{{user_id}}}:cart") # sees the write above
Sharding still uses replicas — but for failover, not for read scaling. Each primary keeps at least one replica so that a node loss promotes the replica rather than losing the slot range; that promotion behavior is the subject of the sibling Redis Sentinel vs Cluster Failover reference. The distinction is deliberate: in Approach B a replica exists to preserve availability, and reads are never routed to it, so no read ever observes replication lag. The price is the operational weight of running a Redis cluster — slot migration during rebalancing, hash-tag discipline so multi-key operations stay on one slot, and connection pools sized per shard, a concern picked up in Connection Pooling and Client Resilience.
When to Choose Which
Resolve the decision against three concrete signals rather than reaching for replicas because they are the obvious throughput lever. The diagram encodes the primary branch; the numbered criteria refine it.
- Staleness tolerance versus worst-case lag. Measure your replication lag under peak write load (see Verification below), then compare it to the staleness budget of the read. If the budget comfortably exceeds observed lag — reference data, analytics rollups, cache-aside fills for slowly changing rows — replica reads are safe and give you the cheapest throughput multiplier available.
- Read-to-write ratio. Replica reads pay off most when the workload is overwhelmingly read-bound, because a single primary absorbs all the writes while a fleet of replicas absorbs the reads. A workload that is write-heavy gains little from replicas — the primary is still the write bottleneck, and every write must fan out to each replica, so adding replicas raises replication bandwidth without relieving the constraint. Write-heavy workloads scale by sharding instead.
- Read-your-writes requirement. If a read must observe the write that immediately preceded it — a user editing their own profile, a checkout confirming an item was added — route that specific read to the primary or shard the key so its reads and writes share one primary. Never satisfy a read-your-writes contract from a replica; the lag window will violate it intermittently and the bug will be nearly impossible to reproduce.
For most services the answer is the hybrid row of the trade-offs table: route the small set of consistency-critical reads to the primary, and fan the large tolerant remainder across replicas. Back tolerant replica reads with a conservative TTL so that even a badly lagging replica cannot serve unboundedly stale data. The step-by-step wiring of a replica read pool lives in Scaling Reads with Redis Replica Nodes.
Failure Modes and Diagnostics
Three failure modes account for most incidents on the replica read path. Each has a fast diagnosis before you commit to a fix.
Replication lag serving stale reads. A replica falls behind the primary — because of a write burst, a slow network link, or the replica being CPU-starved — and every read routed to it returns pre-write data for the duration of the lag. Diagnose by comparing the primary's replication offset against each replica's acknowledged offset; the gap, in bytes of command stream, is the lag:
# On the primary: read master_repl_offset and each replica's ack offset.
redis-cli -h redis-primary INFO replication
# slaveN:...,offset=<slave_repl_offset> lines show how far each replica trails.
# lag_bytes = master_repl_offset - slave_repl_offset (0 means caught up)
The fix is to bound routing by measured lag: stop sending reads to a replica whose offset gap or INFO replication lag exceeds your staleness budget, and lean on a safety-net TTL so any stale value self-heals. Persistent lag under normal load means the replica is undersized or the link is saturated — scale the replica or move it closer to the primary.
Full resync storm. A replica that loses its connection long enough to fall outside the primary's replication backlog cannot resume from a partial resync; it must request a full synchronization, during which the primary forks to produce an RDB snapshot and streams the entire dataset. Under a flapping link this repeats, and each full sync spikes primary memory and CPU while the replica serves nothing. Diagnose with the sync flags in INFO replication:
redis-cli -h redis-primary INFO replication | grep -E "master_sync_in_progress|sync_full|sync_partial_err"
# master_sync_in_progress:1 during a full sync; a climbing sync_full or
# sync_partial_err count means partial resyncs keep failing — a resync storm.
The fix is to enlarge repl-backlog-size so brief disconnects resume with a partial resync instead of a full one, stabilize the network path, and ensure the replica is not evicting the backlog under its own memory pressure. Until the replica reports it is online, exclude it from read routing so clients do not hang on a node busy loading a snapshot.
Read-your-writes violation after a write to the primary. An application writes to the primary and then issues a follow-up read that the client routes to a replica for capacity; the replica has not yet applied the write, so the read returns the old value and the user sees their change vanish. Diagnose by reproducing the sequence and watching the offset gap at read time:
# Write to the primary, then check whether a replica has caught up before reading.
redis-cli -h redis-primary SET user:1001:name "Ada"
redis-cli -h redis-primary INFO replication | grep master_repl_offset
redis-cli -h redis-replica-1 INFO replication | grep slave_repl_offset
# If slave_repl_offset < master_repl_offset, a replica read here is stale.
The fix is to route read-your-writes reads to the primary rather than a replica, or — where a replica read is required for capacity — use WAIT <numreplicas> <timeout> after the write so the primary blocks until the write has propagated to the required number of replicas before the read proceeds. WAIT trades write latency for read freshness and should be applied only to the reads that genuinely need it.
Verification
Confirm both the lag envelope and the routing behave as intended against a live topology before trusting replica reads under load.
Measure replication lag directly as the byte gap between the primary's offset and each replica's offset — the single most important number for deciding whether a read is safe on a replica:
# Primary offset (authoritative write position).
redis-cli -h redis-primary INFO replication | grep master_repl_offset
# Each replica's applied offset; subtract from the primary's to get lag_bytes.
redis-cli -h redis-replica-1 INFO replication | grep -E "slave_repl_offset|master_link_status"
# master_link_status:up confirms the replica is streaming, not resyncing.
A healthy replica shows master_link_status:up and an offset within a few kilobytes of the primary under steady write load; a large or growing gap means it is trailing and should be pulled from rotation. Instrument this as a continuous metric rather than a one-off check, so a lag regression alerts before it serves stale reads:
import redis.asyncio as redis
async def replication_lag_bytes(primary: redis.Redis, replica: redis.Redis) -> int:
"""Byte gap between the primary's write offset and the replica's applied offset."""
primary_info = await primary.info("replication")
replica_info = await replica.info("replication")
# 0 means fully caught up; a large positive value means the replica is stale.
return primary_info["master_repl_offset"] - replica_info["slave_repl_offset"]
Confirm routing sends reads where you intend. A replica-routed read from a RedisCluster client leaves the replica's read counters climbing while its write counters stay flat; check per-node command stats to prove the split:
# Reads should increment on replicas; writes should only touch primaries.
redis-cli -h redis-replica-1 INFO commandstats | grep -E "cmdstat_get|cmdstat_set"
# cmdstat_get calls rising with cmdstat_set near zero confirms read-only routing.
redis-cli -h redis-replica-1 INFO stats | grep -E "keyspace_hits|keyspace_misses"
Finally, validate read-your-writes on the paths that require it: write a sentinel value to the primary, immediately read it back through the exact client path production uses, and assert the value matches. If a critical read is accidentally routed to a replica, this test catches the intermittent staleness before users do — pair it with the connection-resilience checks in Connection Pooling and Client Resilience so a failover or pool exhaustion never silently reroutes a strict read onto a lagging replica.
Up: Redis High Availability & Failover Automation
Related
- Scaling Reads with Redis Replica Nodes — the step-by-step wiring of a replica read pool with lag-aware routing.
- Redis Sentinel vs Cluster Failover — how replicas are promoted when a primary is lost.
- Connection Pooling and Client Resilience — sizing pools and keeping strict reads off lagging replicas.
- Redis Cluster Slot Allocation Basics — the slot model that scales reads by sharding instead of replication.
- TTL vs Explicit Invalidation — the safety-net TTL that bounds staleness on replica reads.