Caching & Materialized Reads for Stale-Tolerant Workloads

Most read paths that use replicas eventually acquire a second staleness source, and often a third. A cache goes in front to cut latency; a materialized view goes underneath to cut computation. Each is a reasonable decision in isolation, and together they produce a read path whose actual freshness nobody has calculated. The operational problem this page addresses is that staleness composes: the layers add, they do not overlap, and the total is routinely several times what the team believes it is.

This is the applied side of replication lag and consistency management. Lag measurement tells you how stale one replica is; this page is about what happens to that number after you put two more layers on top of it, and how to keep the composed total inside a budget you have actually written down.


Concept and Scope: Three Sources of Age

Every value your read path returns has an age, and that age is the sum of three independent contributions.

Replication lag — the interval between the transaction committing on the primary and the replica having replayed it. Typically milliseconds, occasionally seconds, unbounded during a batch job.

Materialization interval — for a materialized view or a rollup table, the time since the last refresh. This is usually the largest of the three and the most predictable, because you schedule it.

Cache residency — how long the value has been sitting in the cache since it was written there. Bounded above by the TTL, and added to whatever age the value already had when it was cached.

The trap is that all three are usually reasoned about separately, by different people, at different times. The replica has a 500 ms lag budget. The materialized view refreshes every 5 minutes. The cache TTL is 60 seconds. Each is defensible; the composed path can serve a value 6 minutes and 0.5 seconds old, and no dashboard shows that number anywhere.

A useful distinction: bounded staleness means you can state a maximum age and enforce it; unbounded staleness means you cannot. A cache with a TTL is bounded. A cache invalidated only by application events is unbounded, because a missed invalidation persists forever. Almost every serious cache-correctness incident traces back to a layer that was assumed bounded and was not.

Scope: this page covers read paths that may serve slightly old data. Read paths that must not — a user reading back their own write, a balance check before a transfer — belong to read-your-writes consistency and should not be cached at all without an explicit watermark check.

How staleness composes along a cached, materialized read path A horizontal age axis. A commit happens at zero. Replication lag adds half a second before the replica has the row. The materialized view refresh interval adds up to five minutes before the aggregate reflects that row. Cache residency adds up to sixty more seconds before a cached copy expires. The bracket beneath spans all three and is labelled as the age a caller may actually observe, which is the sum rather than the largest single term. age of the value a caller sees → commit lag 0.5 s replica replay materialized view refresh — up to 5 min scheduled on the primary cache TTL — up to 60 s already stale when written worst observable age ≈ 6 min 0.5 s not 60 s, and not 5 min — the terms add If your budget is one minute, no combination of these three settings meets it. Change the architecture, not the TTL.

Mechanism Deep-Dive: Where Each Layer Gets Its Freshness

A cache read from a replica inherits the replica’s lag

The subtlety that catches teams is when the age starts counting. A cache entry’s TTL starts at the moment of writing, but the value’s age started at the commit that produced it. If the read that populated the cache came from a replica two seconds behind, the entry begins life two seconds old.

python
# Wrong: TTL is treated as the value's whole age budget.
def get_profile(uid):
    if (v := cache.get(f"profile:{uid}")) is not None:
        return v
    v = replica.query("SELECT * FROM profiles WHERE id = %s", uid)
    cache.set(f"profile:{uid}", v, ttl=60)          # v may already be 2 s old
    return v

# Better: carry the observed age with the value and shorten the TTL to fit
# the remaining budget. Freshness becomes an explicit, inspectable quantity.
BUDGET_S = 60

def get_profile(uid):
    if (e := cache.get(f"profile:{uid}")) is not None:
        return e["value"], time.time() - e["as_of"]
    row, lag_s = replica.query_with_lag(
        "SELECT * FROM profiles WHERE id = %s", uid)
    remaining = max(BUDGET_S - lag_s, 1)            # never below one second
    cache.set(f"profile:{uid}",
              {"value": row, "as_of": time.time() - lag_s},
              ttl=remaining)
    return row, lag_s

The second version costs one extra field and turns an unmeasurable property into a measurable one. as_of is the commit-time the value reflects, so any caller — or any dashboard — can compute the true age rather than the cache-residency time.

Invalidation must be driven from the primary

An invalidation that races with a repopulation from a lagging replica leaves the cache holding stale data with a fresh TTL, which is the worst of both worlds: bounded staleness has silently become unbounded.

The failure sequence is precise. A write commits on the primary. The application invalidates the cache key. A concurrent read misses the cache, queries a replica that has not yet replayed the write, and repopulates the key with the pre-write value and a full TTL. The invalidation happened and the stale value survives.

Two mechanisms close this:

python
# Mechanism 1 — delete-after-lag. Invalidate twice: immediately, and again
# after the replication lag window has certainly passed.
def invalidate(key, lag_budget_s=2.0):
    cache.delete(key)
    schedule_after(lag_budget_s, lambda: cache.delete(key))

# Mechanism 2 — version gate. The primary's commit bumps a version that any
# repopulation must match, so a value read from a stale replica cannot win.
def repopulate(key, uid):
    want = primary.query("SELECT xmin FROM profiles WHERE id = %s", uid)
    row, seen = replica.query_with_version(
        "SELECT *, xmin FROM profiles WHERE id = %s", uid)
    if seen != want:
        return row              # serve it, but do NOT cache a version we know is behind
    cache.set(key, row, ttl=BUDGET_S)
    return row

Mechanism 1 is cheap and usually sufficient; the second delete costs one operation and closes the window for any lag under the budget. Mechanism 2 is exact but pays a primary round trip on every cache miss, which frequently defeats the reason the cache exists. Choose the first unless correctness genuinely requires the second.

Materialized views refresh on the primary and arrive by replication

REFRESH MATERIALIZED VIEW is a write. It cannot run on a standby. The refreshed contents reach replicas through normal WAL replay, which means a materialized view on a replica is always at least one replication interval behind its own refresh — and the refresh itself is a large write that can increase lag while it replays.

sql
-- CONCURRENTLY avoids the ACCESS EXCLUSIVE lock, so readers on the primary
-- and on replicas are not blocked. It requires a unique index on the view.
CREATE UNIQUE INDEX IF NOT EXISTS mv_daily_totals_pk
  ON mv_daily_totals (account_id, day);

REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_totals;

-- Record when the refresh completed so readers can compute the view's age
-- instead of assuming it matches the schedule.
INSERT INTO mv_refresh_log (view_name, refreshed_at)
VALUES ('mv_daily_totals', clock_timestamp());

Without CONCURRENTLY, the refresh takes an ACCESS EXCLUSIVE lock. When that lock is replayed on a hot standby it conflicts with any query reading the view, and those queries are cancelled — the mechanism described in detecting hot standby query cancellations on Postgres replicas. A nightly non-concurrent refresh is a scheduled outage for every replica reader of that view.

The refresh log matters more than it looks. Without it, the view’s age is inferred from the cron schedule, which is exactly the quantity that becomes wrong when a refresh is slow, skipped, or still running.


Trade-off Comparison

Layer Cuts Staleness it adds Bounded? Fails by
Read replica Primary CPU and connection load Replication lag — ms to s, unbounded under batch load Only if you enforce a lag gate Serving a stale row silently
Key-value cache (TTL) Round-trip latency; repeated identical reads TTL, plus the age the value had when cached Yes, if TTL is set Compounding with the layer beneath it
Key-value cache (event-invalidated, no TTL) Same, with better freshness when it works Zero in theory, unbounded in practice No One missed invalidation persisting forever
Materialized view Repeated expensive computation Refresh interval Yes, by schedule A slow or skipped refresh nobody notices
Rollup table written by the app Same, with incremental updates Update lag, usually small Depends on the writer Drift from the source of truth
Client-side / CDN cache Everything, including the network Its own TTL on top of all the above Yes, if TTL is set Being invisible to every server-side metric

Always give an event-invalidated cache a TTL as well, even a long one. The TTL is not the freshness mechanism — the invalidation is — but it converts “stale forever” into “stale for at most one TTL”, and that difference is what separates a degradation from an incident.


Configuration Runbook

A composed read path with an explicit budget

python
# read_path.py — one place where the whole budget is stated and enforced.
# Every layer subtracts from the same number; nothing gets its own budget.
TOTAL_BUDGET_S = 30.0

def read_dashboard(account_id):
    spent = 0.0

    entry = cache.get(f"dash:{account_id}")
    if entry and (age := time.time() - entry["as_of"]) < TOTAL_BUDGET_S:
        return entry["value"], age                    # inside budget: serve it

    # Cache miss or over budget — go to the data, accounting as we go.
    view_age = mv_age_seconds("mv_daily_totals")      # from mv_refresh_log
    spent += view_age
    if spent > TOTAL_BUDGET_S:
        # The view alone has consumed the budget: nothing downstream can fix
        # this. Serve from the primary or fail loudly — never cache it.
        return primary.query(LIVE_SQL, account_id), 0.0

    row, lag_s = replica.query_with_lag(VIEW_SQL, account_id)
    spent += lag_s
    if spent > TOTAL_BUDGET_S:
        return primary.query(LIVE_SQL, account_id), 0.0

    cache.set(f"dash:{account_id}",
              {"value": row, "as_of": time.time() - spent},
              ttl=max(TOTAL_BUDGET_S - spent, 1))
    return row, spent

The structure is the point: a single budget, decremented by each layer, with an escape hatch to the primary when the budget is exhausted before the cheapest path can be used. This makes the staleness question answerable at any moment rather than a matter of adding up configuration files.

Refresh scheduling that does not fight replication

yaml
# Refresh windows chosen against replication behaviour, not just business hours.
materialized_views:
  - name: mv_daily_totals
    refresh: "*/5 * * * *"
    concurrently: true          # required: replicas read this view continuously
    skip_if_lag_above_ms: 1000  # do not add a large write while replicas are behind
    alert_if_age_exceeds_s: 900 # 3× the interval — catches silent refresh failure
  - name: mv_monthly_rollup
    refresh: "17 3 * * *"       # off-peak; :17 avoids the top-of-hour job pile-up
    concurrently: true
    alert_if_age_exceeds_s: 172800

skip_if_lag_above_ms prevents the compounding failure where a refresh runs while replicas are already behind, adds a large write to the WAL stream, and pushes them further behind — the mechanism behind many “the dashboard job takes down the read tier” incidents.

The invalidation race between a cache delete and a lagging replica read A sequence across three lanes: the primary, the cache, and a lagging replica. The write commits on the primary and the application deletes the cache key. Before the replica has replayed the write, a concurrent read misses the cache, queries the replica, receives the pre-write value, and writes it back with a full time to live. The invalidation has been undone. A second delete scheduled after the lag window closes the race. primary cache replica (2 s behind) commit DELETE key miss read — write not replayed yet SET pre-write value, full TTL the invalidation has been undone lag window — 2 s replayed second DELETE at commit + lag budget closes the race for one extra op Any invalidation strategy that does not account for the lag window is a strategy that loses this race intermittently.

Monitoring and Alerting Signals

The instinct is to monitor cache hit rate. Hit rate tells you about cost, not about correctness — a 99% hit rate on stale data is worse than a 60% hit rate on fresh data. Monitor age instead.

promql
# 1. Observed age of served values, at the tail. This is the real SLI.
histogram_quantile(0.99, sum by (le, path) (rate(read_value_age_seconds_bucket[5m])))

# 2. Budget consumption — what fraction of the stated budget the path uses.
histogram_quantile(0.99, sum by (le, path) (rate(read_value_age_seconds_bucket[5m])))
  / on(path) group_left read_path_budget_seconds > 0.8

# 3. Materialized view age against its own schedule. Catches a silently
#    failing or overrunning refresh, which the cron exit code often does not.
time() - mv_last_refresh_timestamp_seconds{view="mv_daily_totals"} > 900

# 4. Repopulation-from-stale rate — how often a cache write used a replica
#    read that was already outside the per-read lag allowance.
rate(cache_set_from_stale_replica_total[5m]) > 0

# 5. Refresh replay impact: replica lag attributable to the refresh window.
max_over_time(pg_replication_lag_seconds[10m])
  and on() (hour() == 3)

Signal 1 requires instrumenting the read path to emit the age it computed — the as_of field from the runbook above. That instrumentation is the difference between knowing your staleness and estimating it from configuration, and it is a few lines of code. Signal 4 is the direct measurement of the invalidation race; a non-zero rate means the race is being lost, and the second-delete mechanism is the fix.


Failure Modes and Recovery Steps

  1. Compounded staleness exceeds the budget nobody wrote down. Diagnosis: users report data older than any single layer’s setting allows; signal 1 shows a p99 far above expectation. Recovery: shorten the largest term, which is almost always the materialization interval rather than the cache TTL. Prevention: a single budget decremented by each layer, as in the runbook.

  2. Lost invalidation race repopulates stale data. Diagnosis: a specific key serves pre-write data for a full TTL after a write, reproducibly under load and never in a quiet environment. Recovery: delete the key manually, then deploy the scheduled second delete. Prevention: delete-after-lag on every invalidation path.

  3. Non-concurrent refresh cancels replica queries. Diagnosis: ERROR: canceling statement due to conflict with recovery clustered exactly at refresh time. Recovery: switch to REFRESH ... CONCURRENTLY, which needs a unique index on the view. Prevention: make CONCURRENTLY the default for any view a replica reads.

  4. Refresh job fails silently and the view ages out. Diagnosis: the data looks plausible but is hours old; the cron job reports success because the failure was inside a transaction that rolled back. Recovery: refresh manually and backfill. Prevention: signal 3 — alert on the view’s observed age from the refresh log, never on the scheduler’s exit code.

  5. Refresh worsens replication lag, which worsens everything downstream. Diagnosis: replica lag spikes during the refresh window and the cache begins caching values that were already stale. Recovery: stagger or skip the refresh. Prevention: skip_if_lag_above_ms, and schedule large refreshes away from other batch writes — the interaction is the same one described in graceful degradation when all replicas exceed the lag budget.


Production-Readiness Checklist


In This Section


FAQ

Does a cache in front of a read replica make staleness worse?

Yes — the two add. A cache entry populated from a replica that was two seconds behind, then held for a thirty-second TTL, can serve data that is thirty-two seconds old. Teams routinely size the TTL against the freshness requirement and forget that the entry was already stale when it was written. Budget the sum, not each layer separately.

Should I invalidate the cache from the application or from the database?

From wherever the write is authoritative, which is the primary. An invalidation triggered by application code immediately after a write is correct; an invalidation triggered by a replica-side change feed is not, because it arrives after replication lag and can race with a repopulation from the same lagging replica. If you must drive invalidation from the database, drive it from logical decoding on the primary.

When is a materialized view better than a cache?

When the expensive part is the computation rather than the round trip, and when many different callers need the same aggregate. A materialized view moves the cost to a scheduled refresh and keeps the result inside the database where it is queryable and joinable. A cache is better when the result is per-key, the working set is small, and you need sub-millisecond reads. The two are not alternatives — a materialized view refreshed on the primary and read from replicas, fronted by a short cache, is a common and reasonable shape.

Can I refresh a materialized view on a read replica?

No. REFRESH MATERIALIZED VIEW is a write and a standby is read-only, so the refresh must run on the primary and reach replicas through normal replication. That has a consequence worth planning for: a non-concurrent refresh takes an ACCESS EXCLUSIVE lock which, once replayed on a standby, can cancel queries reading that view. Use REFRESH ... CONCURRENTLY, or schedule refreshes where a brief replica-side cancellation is acceptable.


← Back to Replication Lag & Consistency Management