Skip to content

Cloud & Infrastructure · Database Architecture

Materialized Views vs Read Replicas vs Caching

Three tools solve three different versions of "this query is slow." How materialized views, read replicas, and caches differ, and how to pick one.

Abhishek Gupta

Abhishek Gupta

6 min read

Materialized Views vs Read Replicas vs Caching

Sponsored

Share

“Our dashboard query is slow” gets the same three answers in most engineering orgs: add a read replica, cache it, or materialize it. Teams usually reach for whichever one they used last time, and it often works well enough that nobody stops to ask whether it was the right tool for this particular slowness. It matters, because these three solve genuinely different problems, and picking wrong means paying the cost of one fix while still carrying the symptom the other one was built to remove.

Three different versions of “slow”

Before picking a fix, name which version of slow you actually have.

The query is fundamentally expensive. A report that joins five tables and aggregates across a year of transactions takes four seconds no matter how many times you run it, because the work itself is heavy: scanning rows, sorting, grouping. Running it more often, or from a different server, doesn’t make the computation cheaper.

The database is under too much read load. Individual queries are fine, fast even, but there are so many of them hitting the primary that they compete with write throughput and with each other, and latency degrades across the board under concurrency.

The same specific reads happen over and over. A product page, a config value, a user’s own profile: the underlying query might be perfectly fast, but it runs thousands of times a minute for the same handful of results.

These map cleanly to materialized views, read replicas, and caching, in that order, and mixing them up is where the wrong fix gets shipped.

Materialized views: precompute the expensive part

A materialized view is a query whose result set PostgreSQL (or your database of choice) stores as an actual table-like object, rather than recomputing on every read. You define it once:

CREATE MATERIALIZED VIEW monthly_revenue_by_region AS
SELECT
  region,
  date_trunc('month', order_date) AS month,
  sum(total_amount) AS revenue,
  count(*) AS order_count
FROM orders
JOIN customers ON customers.id = orders.customer_id
GROUP BY region, date_trunc('month', order_date);

CREATE UNIQUE INDEX ON monthly_revenue_by_region (region, month);

Querying it is now a straightforward indexed read instead of a live join-and-aggregate over the full orders table. That is the entire value proposition: the expensive work happens once, at refresh time, and every read after that is cheap.

The catch is the view is a snapshot. It doesn’t update when the underlying tables change. You refresh it explicitly:

REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue_by_region;

The CONCURRENTLY option matters in production. A plain REFRESH takes an exclusive lock and the view is unreadable until it finishes, which is a real problem if the view backs a page people are actively looking at. CONCURRENTLY builds the new result set alongside the old one and swaps atomically, so reads keep working through the refresh. It requires a unique index (the one created above) and costs more, since Postgres has to diff old rows against new ones instead of just truncating and rewriting.

Refresh on a schedule (a cron job every 15 minutes for a dashboard that can tolerate that staleness), on a trigger after specific writes, or on demand from application code after a batch job. The right cadence depends entirely on how stale is acceptable for what the view backs, and that’s a product decision, not a database one.

Read replicas: scale volume, not cost per query

A read replica is a full copy of the database that receives a continuous stream of changes from the primary and serves reads independently. It doesn’t make any individual query cheaper. It gives you more machines capable of running the same queries, which is exactly the right fix when the problem is concurrent read volume rather than any single query being expensive.

Where teams go wrong is routing an expensive aggregation query to a replica and calling the problem solved. It usually helps, because the primary is no longer competing with that query for resources, but the query itself is still slow, the replica is just now the one waiting four seconds instead of the primary. If the report page still feels sluggish after adding a replica, that’s the signal the actual problem was query cost, not read contention, and a materialized view was the fix that was needed. For the operational details of running replicas well, including the read-your-writes consistency problem they introduce, see our replication lag deep dive; it covers ground this post doesn’t repeat.

Caching: eliminate repeated identical work

A cache, typically Redis or an in-memory store, holds the result of a specific computation under a key, and returns that stored result on subsequent lookups instead of recomputing it. It’s the right tool when a small number of distinct queries or computations get requested disproportionately often, the classic hot-key pattern.

def get_product_page(product_id):
    cache_key = f"product:{product_id}"
    cached = redis.get(cache_key)
    if cached:
        return json.loads(cached)

    result = expensive_product_query(product_id)
    redis.setex(cache_key, 300, json.dumps(result))  # 5 minute TTL
    return result

The tradeoff is invalidation, the famously hard half of caching. A materialized view refreshes on a schedule you control from inside the database. A cache has to be told when it’s wrong, either by a TTL that eventually expires it (simple, but means it can serve stale data for up to the TTL window) or by explicit invalidation when the underlying data changes (accurate, but means every write path that could affect a cached key needs to know to clear it, which is easy to miss as a codebase grows).

Picking one, or combining them

FixesFreshness modelBest for
Materialized viewExpensive query computationStale since last refreshOne known expensive aggregation or report
Read replicaRead throughput / concurrencyContinuously catching up (lag)General read scaling across many query shapes
CacheRepeated identical readsStale until TTL or invalidationA small set of hot keys read far more than they change

A system with real scale usually ends up using more than one. A materialized view handles the one gnarly reporting query. Read replicas absorb the general read traffic from the application. A cache sits in front of the highest-traffic individual keys, view or replica included. None of the three replaces the others, because none of them solves the problem the others were built for.

The move that actually saves time is spending five minutes diagnosing which version of “slow” you have before reaching for a fix. A materialized view thrown at a read-concurrency problem does nothing. A read replica thrown at an expensive aggregation just relocates the same four seconds somewhere else. Match the tool to the actual bottleneck, and it’s usually a small, boring change instead of an infrastructure project. If you’re not sure which one your system needs, that diagnostic step is exactly the kind of thing worth a second pair of eyes before you commit to a migration; our services page covers how we approach that kind of architecture review.

Frequently asked questions

What's the actual difference between a materialized view and a cache?
A materialized view lives inside the database as a real table-like object with its own storage, refreshed by re-running its defining query. A cache lives outside the database, usually in Redis or an in-memory store, and holds serialized results your application put there. The materialized view is queryable with SQL, can be indexed and joined against other tables, and is refreshed by the database. The cache is a key-value lookup your application code manages explicitly, including when to invalidate it.
When does a materialized view beat a read replica?
When the problem is query cost, not query volume. A replica gives you another copy of the same slow query plan; if the query itself takes 4 seconds because it aggregates across millions of rows, running it on a replica instead of the primary still takes 4 seconds, just without hurting the primary. A materialized view precomputes that aggregation once, so subsequent reads hit an indexed table instead of re-running the expensive query at all.
Does refreshing a materialized view lock it?
By default, yes: a plain REFRESH MATERIALIZED VIEW in PostgreSQL takes an exclusive lock and the view is unqueryable until the refresh finishes. REFRESH MATERIALIZED VIEW CONCURRENTLY avoids that by building the new result set alongside the old one and swapping atomically, but it requires a unique index on the view and does more total work, since it has to diff old and new rows instead of just replacing everything.
Can I use all three at once?
Yes, and in a system with real read scaling needs, that's normal rather than excessive. A materialized view handles one specific expensive aggregation. Read replicas absorb general read traffic so the primary stays free for writes. A cache sits in front of both for the small set of keys that get hit disproportionately often. Each layer solves a distinct problem the others don't.
How stale can a materialized view get?
As stale as the time since its last refresh, and unlike a replica, that staleness doesn't shrink on its own. A replica continuously catches up. A materialized view sits exactly as fresh as it was at its last REFRESH call until something triggers another one, whether that's a cron job, a trigger, or an application call after a relevant write. Design the refresh cadence around how stale the data is allowed to be for its actual use case, not around a default interval.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored