Skip to the solution
MediumSystem Design10 min read

When do you use a read replica vs a cache?

705 views

The problem

Question presented to candidate: "Our primary Postgres database is overloaded with reads. One engineer wants to add read replicas, another wants to put Redis in front. How do you decide, and could you use both?"

What a strong answer should cover:

  • The core distinction: a read replica is a full copy of the database that can run any query (joins, filters, aggregates) but is a little behind (replication lag); a cache holds a subset of answers under keys you choose, is far faster per lookup, and stays correct only if you invalidate it
  • Which workload fits which: repeated key lookups of hot objects favour a cache; many different queries, reports and search pages favour replicas
  • Consistency on replicas: asynchronous replication, read-your-writes (route a user to the primary after their own write), monotonic reads (stick a session to one replica)
  • Consistency on caches: invalidation on write, TTLs, and the race of refilling a cache from a lagging replica
  • Cost and operations: replicas cost a whole database instance each and need no application changes beyond routing; a cache needs code for keys, invalidation and failure handling
  • Using both: cache for the hot keys, replicas for the long tail of queries, primary for writes and must-be-current reads

Clarifying questions expected:

  • Are the expensive reads repeated key lookups, or many distinct queries?
  • How stale may a read be, and does a user need to see their own write immediately?

Code / implementation expected: No β€” a clear comparison with the consistency traps is the answer; the example simulates lag, routing and the cache refill race.

  • read-replica
  • caching

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

10 min read Β· 11 sections

Read the code

Read replica vs cache: query power, replication lag, read-your-writes, refilling a cache from a replica
// Read replica vs cache, simulated on a fake clock (milliseconds).
// The primary is a Map plus a replication log; a replica applies each log entry
// LAG ms after it was committed (asynchronous replication). The cache is a Map with TTLs.

class Primary {
  constructor() { this.rows = new Map(); this.log = []; }
  write(t, id, row) { this.rows.set(id, { ...row }); this.log.push({ t, id, row: { ...row } }); }
}
class Replica {
  constructor(primary, lag) { this.p = primary; this.lag = lag; this.rows = new Map(); this.applied = 0; }
  catchUp(now) { // apply every log entry that is at least LAG ms old
    while (this.applied < this.p.log.length && this.p.log[this.applied].t + this.lag <= now) {
      const e = this.p.log[this.applied++]; this.rows.set(e.id, { ...e.row });
    }
  }
  get(now, id) { this.catchUp(now); return this.rows.get(id); }
  query(now, fn) { this.catchUp(now); return fn([...this.rows.values()]); }
}
class Cache {
  constructor() { this.m = new Map(); }
  get(now, k) { const e = this.m.get(k); return e && e.exp > now ? e.v : undefined; }
  set(now, k, v, ttl) { this.m.set(k, { v, exp: now + ttl }); }
  del(k) { this.m.delete(k); }
}

const db = new Primary();
const cities = ["Pune", "Oslo", "Lima"];
for (let id = 1; id <= 12; id++) db.write(0, id, { id, name: "user" + id, city: cities[id % 3], followers: (id * 37) % 100 });
const fast = new Replica(db, 50), slow = new Replica(db, 400);

console.log("Part 1: what each one can answer (t=1000 ms)");
const top = fast.query(1000, (rs) => rs.filter((r) => r.city === "Oslo").sort((a, b) => b.followers - a.followers).slice(0, 3).map((r) => r.name + ":" + r.followers));
console.log("  replica, top 3 in Oslo by followers (filter + sort):", top.join(" "));
const cache = new Cache();
cache.set(1000, "user:4", db.rows.get(4), 60000);
console.log("  cache, GET user:4:", cache.get(1000, "user:4").name);
console.log("  cache, top 3 in Oslo:", cache.get(1000, "top:Oslo") === undefined ? "miss (nobody cached that exact answer)" : "hit");

console.log("\nPart 2: read-your-writes and monotonic reads");
db.write(2000, 7, { ...db.rows.get(7), name: "Asha" });
console.log("  t=2000 user 7 renames to Asha on the primary");
console.log("  t=2100 read from slow replica (lag 400):", slow.get(2100, 7).name, "<- the user sees the old name");
const pinnedUntil = 2000 + 1000; // route this user to the primary for 1 s after their own write
const route = (now) => (now < pinnedUntil ? db.rows : slow.rows);
console.log("  t=2100 same read, pinned to primary after a write:", route(2100).get(7).name);
console.log("  session B, t=2060 read from fast replica (lag 50):", fast.get(2060, 7).name);
console.log("  session B, t=2080 next read lands on slow replica:", slow.get(2080, 7).name, "<- went back in time");

console.log("\nPart 3: cache refilled from a lagging replica after an invalidation");
for (const strategy of ["fill from replica", "fill from primary", "fill from replica + delete again after lag"]) {
  const c = new Cache();
  const p = new Primary();
  p.write(0, 9, { name: "v1" });
  const r = new Replica(p, 400);
  c.set(0, "user:9", { name: "v1" }, 3600000);
  p.write(5000, 9, { name: "v2" }); c.del("user:9"); // write, then invalidate
  let stale = 0, dbReads = 0;
  for (let now = 5010; now < 65010; now += 100) { // one read every 100 ms for a minute
    if (strategy.endsWith("after lag") && now === 5510) c.del("user:9"); // second delete at write + lag + 100
    let v = c.get(now, "user:9");
    if (v === undefined) {
      dbReads++;
      v = strategy === "fill from primary" ? p.rows.get(9) : r.get(now, 9);
      c.set(now, "user:9", v, 3600000);
    }
    if (v.name !== "v2") stale++;
  }
  console.log(`  ${strategy.padEnd(42)} stale reads ${String(stale).padStart(3)} of 600, source reads ${dbReads}`);
}

console.log("\nPart 4: sizing for 20000 reads/s (assumed capacities)");
const reads = 20000, replicaCap = 4000, cacheCap = 100000, hitRatio = 0.95;
console.log(`  replicas only: ${Math.ceil(reads / replicaCap)} replicas at ${replicaCap} reads/s each`);
const misses = Math.round(reads * (1 - hitRatio));
console.log(`  cache (95% hits) + primary: ${Math.ceil(reads / cacheCap)} cache node, ${misses} misses/s reach the database`);

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 25 of 106 in System design

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

How do you choose between SQL and NoSQL and scale each (replication, sharding, federation)?

Continue