Read Replicas vs Caching vs Both: The Decision Matrix
Share This Article
The meeting usually goes the same way. A dashboard turns red, p95 latency has doubled over a month, and the primary database is sitting at 80 percent CPU during peak. Someone says “add a read replica.” Someone else says “just cache it.” Both sound reasonable, both get nods, and half the time the team ends up doing whichever one somebody has done before.
Here’s the thing: both answers are reflexes, and reflexes skip the diagnosis. A read replica and a cache look interchangeable from a distance because they both take reads off the primary. Underneath, they solve different failures, fail in different ways, and cost very different amounts of money.
My narrower point: a replica adds capacity, a cache removes repeated work, and which one you need falls out of two questions about your traffic. This post is the decision matrix I use. It stops applying the moment your problem is writes, because neither tool helps a write-bound primary.
Key takeaways
- Replicas add read capacity; caches remove repeated work. They solve different failures, and naming yours correctly is most of the decision.
- A cache wins when a few hot keys dominate traffic. A replica wins when the read set is broad and mostly tolerates slight staleness.
- The cheap order is usually: fix the queries, cache the hot keys, then add a replica for breadth or isolation. Most teams run it backwards.
- Replication lag is usually sub-second but never guaranteed. Route read-your-own-writes traffic to the primary from day one.
- Adding both on reflex doubles your spend and hands you two consistency problems instead of one.
Is your problem latency or load?
Name the failure before picking the fix: a replica helps when the primary is out of capacity to serve reads, a cache helps when the same work is being done over and over, and neither helps when individual queries are simply slow. The reflex is to reach for infrastructure. The cheaper move is almost always to look at the query stats first.
Three symptoms, three different answers. If CPU is pegged and connections are queuing while individual queries look fine, that’s load. If the box is half idle but a page takes two seconds, that’s a slow query, and the fix is an index or a rewrite, not more hardware. If the same result is being computed thousands of times a minute, that’s cache-shaped traffic hiding inside what looks like a load problem.
Where do you look? pg_stat_statements answers most of this in one query:
SELECT query,
calls,
ROUND(total_exec_time::numeric / 1000, 1) AS total_seconds,
ROUND(mean_exec_time::numeric, 2) AS mean_ms
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 15;High calls with low mean_ms is volume, and probably cache-shaped volume. Low calls with high mean_ms is a slow query wearing a capacity costume. High everything is where replicas start to earn their keep.
Where’s the line? If the top offenders are slow because of missing indexes or bad plans, fix those first. I’ve watched a team provision a replica to serve a query that a partial index made roughly forty times cheaper a week later. Infrastructure can’t outrun a bad plan; it just runs the bad plan in two places.
Indexing strategies and when each index type pays off → post 7.
When do read replicas actually pay off?
A read replica pays off when your read set is broad, mostly tolerant of slightly stale data, and heavy enough that the primary is genuinely out of headroom. Think dashboards, reporting, search results pages, product listings: lots of different queries, none of them needing this-millisecond freshness, all of them together eating CPU the primary needs for writes.
[PERSONAL EXPERIENCE] The Monday-morning BI workload that tripled p99 on an order-processing primary until reporting moved to a replica.
The cleanest replica win I’ve seen was a system where analytics shared a primary with checkout traffic. Every Monday morning the BI dashboards hammered the same instance that processed orders, and p99 on the order path tripled like clockwork. Moving reporting to a replica made nothing faster in isolation. It made the two workloads stop fighting, which was the actual problem.
The mechanics explain the strengths. A Postgres streaming replica replays the primary’s WAL, so it carries the full dataset and can serve any read the primary can. That breadth is the point: you don’t have to predict which queries will move, you just route them. The default is asynchronous replication, which keeps write latency on the primary untouched; synchronous modes exist but make every commit wait, which is a different trade entirely.
The price is equally plain. You’re running a full second instance, and on RDS a replica is typically the same instance class as the primary, so the database line on your bill roughly doubles per replica. Capacity scales linearly and so does spend. Aurora changes the shape of that trade rather than removing it: its replicas share the storage layer, which keeps lag low and adds capacity faster, but each reader is still a billed instance you have to justify.
Database choices that quietly kill your AWS bill → post 5.
Monitoring is not optional, because lag is where replicas bite:
-- On the replica: how far behind is replay?
SELECT now() - pg_last_xact_replay_timestamp() AS replica_lag;
-- On the primary: a per-replica view
SELECT client_addr,
state,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes
FROM pg_stat_replication;Where’s the line? Replicas stop paying off in three places. Read-your-own-writes flows break on lag unless you route them back to the primary. Hot-key traffic doesn’t spread: if one expensive query dominates, the replica re-executes it just as often as the primary did. And a write-bound primary gets no relief at all, since every write still lands on it.
When does a cache beat a read replica?
A cache beats a replica when a small set of keys absorbs most of your read traffic. Real traffic is rarely uniform: a handful of products, tenants, or sessions usually accounts for a disproportionate share of reads. A replica re-executes the query for every one of those hits. A cache runs the query once and serves the computed result from memory until it changes.
That difference shows up in both latency and money. A cache hit is typically sub-millisecond, while even a fast indexed query costs more once you count the round trip, planning, and buffer reads. The hardware asymmetry is just as hard to ignore: a small Redis or Memcached node costs a fraction of a full-size database instance, because it holds computed results, not the whole dataset. You can also cache above the row level, and often should: a rendered fragment or an assembled API response saves more work per hit than a cached row ever will.
So why doesn’t everyone just cache everything? Invalidation, mostly. The standard cache-aside pattern is easy to write and easy to get subtly wrong:
def get_product(product_id):
cached = redis.get(f"product:{product_id}")
if cached is not None:
return deserialize(cached)
row = db.query("SELECT ... FROM products WHERE id = %s", product_id)
redis.set(f"product:{product_id}", serialize(row), ex=300)
return rowEvery cached key is now a freshness contract you have to honour on every write path that touches that data. TTLs cap the damage but guarantee some staleness. Explicit invalidation is precise but grows into a dependency graph nobody fully remembers. And a popular key expiring under load can stampede the database with the very query you were protecting it from.
Where’s the line? Caches lose when the read set is broad and rarely repeats, because there’s nothing to hit. They lose when correctness demands this-second freshness you can’t invalidate reliably. And they lose when the invalidation graph grows complex enough that cache bugs cost more engineering time than the database load ever did.
The layering order most teams get backwards
The cheap order is: fix the queries, cache the obvious hot keys, then add a replica when breadth or isolation outgrows the cache. Most teams run it in reverse, and the reason is friction, not analysis.
[UNIQUE INSIGHT] Replica-first happens because it’s a console checkbox with zero code changes, not because the traffic shape called for it. The cache gets built later anyway, and then both run side by side with one mostly idle.
A replica is a checkbox in the RDS console and zero application changes. A cache is a code change, an invalidation design, and a new failure mode to reason about. So the replica ships first, the bill doubles, and six months later the cache gets built anyway because the hot keys were the real problem all along. Now both are running, one of them mostly idle, and nobody wants to be the person who turns either off. Sound familiar?
What flips the order? Workloads that need isolation rather than acceleration. Compliance reporting, analytics teams with ad hoc query access, batch exports: those want a replica first regardless of hit rates, because the goal is blast-radius containment. An analyst’s accidental sequential scan should never share a CPU with checkout.
Where’s the line? If your app is read-your-own-writes heavy (feeds, collaborative editing, anything where a user immediately reads what they just wrote), cache-first weakens too, because per-user invalidation is exactly the hard kind. Sometimes the honest answer is that neither layer helps and the schema or access pattern needs work first.
Zero-downtime database migrations, patterns and pitfalls → post 9.
How bad is replication lag in practice?
On a healthy Postgres streaming replica, lag usually sits below a second, often in the tens of milliseconds. The problem isn’t the typical case. The problem is that lag has no upper bound, and the spikes arrive exactly when the system is busiest.
Bulk imports, large transactions, index builds, and aggressive vacuum activity all inflate lag. A replica reading 50 milliseconds behind at 10am can read 40 seconds behind during a backfill. If a product flow only breaks when lag crosses a threshold, it will break during the incident, the migration, or the traffic spike: precisely the moments you least want a new failure mode joining the party.
How do you live with that? Design for lag instead of hoping it stays small. Route read-your-own-writes to the primary unconditionally. Pin a session to the primary for a few seconds after it performs a write. Export replay lag as a first-class metric and alert on it like any other SLO, because a silently lagging replica serves confidently wrong answers, which is worse than serving none.
-- Alert-friendly lag in seconds, run on the replica
SELECT EXTRACT(EPOCH FROM now() - pg_last_xact_replay_timestamp()) AS lag_seconds;Where’s the line? If any user-facing flow genuinely can’t tolerate stale reads and can’t be routed to the primary, lag decides the argument by itself. That path gets a cache with explicit invalidation, or no offloading at all. A replica with unbounded staleness is the wrong tool for a bounded-staleness promise.
What does “both” actually look like when you earn it?
Both means layers with distinct jobs, not two copies of the same fix. The cache sits closest to the application and absorbs the hot keys. Replicas sit behind a routing layer and absorb the broad, staleness-tolerant remainder. The primary keeps every write and every read-your-own-writes path. Three tiers, three responsibilities, no overlap.
The routing decision is the part that deserves design attention. Who decides which query goes where? Not individual engineers under deadline pressure, one call site at a time. The rule belongs in a single data-access layer or proxy, where each read path declares a freshness budget and the budget picks the tier. Zero staleness routes to the primary. A few seconds of tolerance routes to a replica. Repeated identical reads route through the cache first.
Two details separate a layered setup that holds from one that falls over together. First, cache misses should fall through to a replica, not the primary, so a cold cache after a deploy or a Redis restart doesn’t stampede the instance that processes writes. Second, protect the hot keys themselves: jittered TTLs so popular entries don’t expire in unison, and request coalescing so a thousand concurrent misses for one key trigger one database query rather than a thousand.
Operationally, each tier gets its own SLO. Cache hit rate, replica replay lag, and primary CPU are three separate signals, and a healthy dashboard shows all three, because a sagging hit rate quietly shifts load down the stack long before anything pages.
Where’s the line? Both stops being worth it below a certain scale. If a single well-indexed primary on a moderate instance still has clear headroom, the operational surface of three tiers costs more than it saves. Don’t build the diagram before the traffic exists to justify it.
So how do you actually decide?
Four questions, asked in this order, settle most cases before anyone opens the AWS console.
1. Is the primary actually out of capacity, or are queries just slow? Read pg_stat_statements before provisioning anything. If mean execution times are the story, fix plans and indexes first. Neither a replica nor a cache repairs a bad query; they just pay for it differently.
When to use each AWS database service → post 6.
2. Do a few keys dominate, or is the read set broad? Skewed traffic points at a cache, because repeated work is exactly what a cache deletes. Broad, unpredictable reads (reporting, search, ad hoc analytics) point at a replica, because breadth is exactly what a full copy of the data provides.
3. How much staleness can each read path tolerate? Paths that must read their own writes stay on the primary, full stop. Every other path gets an explicit freshness budget, and that budget tells you whether a TTL cache or typical replica lag fits inside it.
4. Which is cheaper for you to operate, not just to buy? A replica is more spend and almost no code. A cache is less spend and a real invalidation design. Teams absorb those costs differently, and pretending otherwise is how the wrong tool gets picked for defensible-sounding reasons.
Read the answers together. Skewed plus staleness-tolerant is a cache. Broad plus isolation-shaped is a replica. Growing systems eventually answer yes to both, and that’s fine: both is a milestone you earn, not a starting point you assume.
The matrix beats the reflex
The practical default, restated: fix the queries, then cache the hot keys with honest TTLs, then add a replica when breadth or isolation demands it, and run both only when scale forces the issue. That order keeps spend and complexity proportional to the problem you can actually demonstrate.
Get it backwards and you pay twice. A replica added on reflex doubles the bill without touching hot-key latency. A cache added without an invalidation plan turns into a slow drip of stale-data bug reports. Add both blind and you now own two consistency stories, each with its own distinct 2am pager behaviour.
Read replicas versus caching was never really a versus. It’s a matrix, and the matrix has a row for your traffic. Find the row before you spend the money.
Most teams don’t need a vector database → post 2, same decision-first framing.
Frequently asked questions
Should I add a read replica or a cache first?
Cache first in most cases. If a small set of hot keys dominates read traffic, a modest cache absorbs it for a fraction of a replica’s cost. Add the replica when the read set is broad, reporting needs isolation from transactional traffic, or the cache hit rate stops climbing.
Do read replicas improve write performance?
No. Every write still lands on the primary, and replication adds a small amount of overhead there. Replicas offload reads only. If your primary is write-bound, look at batching, partitioning, or hardware before replicas, because adding replicas can make a write-bound primary slightly worse.
How much replication lag is normal for a Postgres read replica?
Healthy streaming replication usually sits below one second, often in the tens of milliseconds. The catch is that lag isn’t bounded: bulk writes, long transactions, and vacuum activity can push it to seconds or minutes. Monitor it continuously and route read-your-own-writes traffic to the primary.
Is Redis always the right choice for a caching layer?
It’s the common default, and a good one, but not the only option. Memcached is simpler for pure key-value caching, and an in-process cache can beat both for small, hot, read-only data. Choose based on invalidation needs, data structures, and whether the cache must be shared across instances.
Can a read replica replace a cache entirely?
Only when reads are broad and evenly spread. A replica re-executes every query, so it doesn’t help when the same expensive result is fetched thousands of times. For hot-key traffic a cache serves the result without touching the database at all, which a replica can never do.
- June 5, 2026

Naveen Chandra
Hi, I am Naveen Chandra, a Cloud Engineer and Web Developer. I work with companies that take their technology seriously and want a long-term partner, not a short-term contractor. From AWS infrastructure and DevOps automation to full-stack web platforms and React Native apps, I focus on systems that compound in value over years rather than projects that end in weeks.