The last five lessons were all layers that sit in front of the database. This one steps into the database itself, and the first question is the cheapest in the whole series: if your read-to-write ratio is 100:1 — a very common number — then simply adding copies to read from already handles 99% of the work.

This lesson's lab stands up a PostgreSQL primary and a real read replica, with streaming replication. And the very first measurement produced a number I did not see coming: the lab's replication lag is only 0.557 ms — less than half a thousandth of a second — and yet the share of users who "write, read back, and see stale data" was still 87.21%. Lag does not need to be large to break a system; it only needs to be larger than the gap between that same user's write and their next read.

ℹ️ The measurement machine, and one thing to say up front
Apple M1 Max, 10 cores, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Native arm64 containers. PostgreSQL 18.3 (the Debian build), primary and replica in two separate containers, asynchronous streaming replication. Three app replicas at 1 CPU / 256 MB each, nginx 1.27-alpine, load generator on 2 CPUs.

Because both PostgreSQL nodes sit on the same machine, the natural lag is under 1 ms — far smaller than for a replica in another geographic region. So besides the measurements at natural lag, this lesson also uses recovery_min_apply_delay to impose a controlled lag of 200 ms, simulating a distant replica. Every table states which mode it was measured in.

7.1 Three different purposes, usually collapsed into one

"Add a replica" sounds like one job, but it serves three different goals — and those three goals need three different configurations. Collapsing them is the source of most disappointment with replicas.

Purpose What it needs What it does NOT give you
Scaling the read tier Several replicas, async is enough, tolerate slightly stale data No help at all with write throughput — every write still funnels into one primary. It does not increase storage capacity either
High availability (HA) At least semi-synchronous, plus election and fencing, plus a replica in another region You do not get it for free by enabling an async replica and promoting by hand during an incident
A backup copy A deliberately delayed copy, or point-in-time snapshots, or a logical backup somewhere else entirely A replica is not a backup. See the pitfall right below — this is the most expensive misconception of the lot
⚠️ Pitfall: treating a replica as a backup
A replica copies every change, faithfully and very quickly — including that DELETE FROM orders with the missing WHERE you just ran. In the lab the lag is 0.557 ms. That is roughly half a thousandth of a second to regret it.

A replica protects you from hardware failure. A backup protects you from a logic error — yours, a deploy's, or an attacker's. Two different kinds of incident need two different tools. If all you have is a replica then you do not have a backup, and you will find that out at the worst possible moment.

There is something in between, and it is well worth knowing: a deliberately delayed replica. Using exactly the mechanism this lesson uses to simulate lag — recovery_min_apply_delay — you can keep a replica permanently one hour behind. It is still a replica, but it gives you an hour to notice a bad delete and stop it.

7.2 The replication mechanism, and the price of each safety level

PostgreSQL does not copy "the data", it copies the WAL (write-ahead log) — the journal stream recording every change before it is applied. The replica receives that stream and replays it. MySQL calls it the binlog; the idea is the same.

What matters comes down to a single question: at what moment does the primary answer "write committed"? Answer early and writes are fast but data can be lost when the primary dies; answer late and it is safer, but every write has to wait for the replica.

When does the primary answer "write committed" — four choices, four prices primary writes WAL replica RECEIVES replica FLUSHES replica APPLIES measured ASYNC · synchronous_commit = local replica catches up LATER, primary does not wait 0.468 ms 17,096 tps primary dies ⇒ everything unsent is LOST SEMI-SYNC · remote_write waits for the replica to RECEIVE the WAL 0.660 ms 12,122 tps SYNC · on (the default) durable now 0.836 ms 9,574 tps STRONGEST SYNC · remote_apply 0.811 ms 9,867 tps Reads on the replica ALWAYS see fresh data ⇒ measured 0% stale reads (section 7.3) From async to sync: write latency +79%, write throughput −44%. That is the price of not losing data when the primary dies. Both nodes are on the same machine, so the gaps between the markers are tiny. With a replica in another region, each marker adds a cross-region RTT.
The ✓ marks the moment the primary answers the client. The further right the ✓ moves, the safer and the slower — no single choice is right for every case.

The figures in the diagram come from pgbench — the load-measuring tool that ships with every PostgreSQL installation, nothing extra to install. It opens N connections, loops one SQL script for T seconds, and reports transactions per second (tps) and the average latency of a transaction. Here it runs inside the primary container with 8 connections for 10 seconds, and the script is a single INSERT — the less work inside the transaction, the closer the rest is to the pure cost of the commit step, which is exactly what the four synchronisation modes change:

measure_commit_cost.sh
cd blog/sysdesign/sysdesign-lab
docker compose --profile replica up -d

# The smallest possible write script
docker compose exec -T postgres sh -c 'echo "INSERT INTO write_load (filler)
  VALUES (repeat(md5(random()::text), 8));" > /tmp/w.sql'

# Switch mode, then measure again. `walreceiver` is the replica's application_name
# in this lab — read it from: SELECT application_name FROM pg_stat_replication;
for cfg in "'':local" "walreceiver:remote_write" \
           "walreceiver:on" "walreceiver:remote_apply"; do
  names="${cfg%%:*}"; mode="${cfg##*:}"
  docker compose exec -T postgres psql -U lab -d lab -q \
    -c "ALTER SYSTEM SET synchronous_standby_names = $names;" \
    -c "ALTER SYSTEM SET synchronous_commit = '$mode';" \
    -c "SELECT pg_reload_conf();"
  echo "== $mode"
  docker compose exec -T postgres pgbench -U lab -d lab -n -f /tmp/w.sql -c 8 -j 2 -T 10
done
⚡ One surprise in the measurements
remote_apply measured 0.811 ms — very slightly faster than on at 0.836 ms, even though in theory it waits for more (for the replica to finish applying, not just flushing). That 0.025 ms gap is within run-to-run variation, so the correct conclusion is that these two modes cost the same in this lab, not that remote_apply is cheaper.

The reason: both nodes are on the same machine, so the "apply" step is nearly free next to the "flush to disk" step. With a replica in another region that distance widens sharply and remote_apply becomes distinctly more expensive. This is a good example of something to keep in mind throughout the series: the lab removes geographic distance, so it measures the mechanism correctly but not the proportions.
⚠️ Pitfall: enabling synchronous mode with a replica in another region
If synchronous_standby_names points at a replica in another geographic region, every write has to wait a full cross-region RTT. With the number from Lesson 6 — 150 ms for a long route — maximum write throughput on one connection drops to about 6 transactions per second, no matter how powerful the machine is.

The signature is very distinctive: write throughput collapses while the database's CPU stays idle. Nothing is computing — everything is waiting on the network. If you see that combination, check synchronous_standby_names before you go off optimising queries.

7.3 Replication lag, and the bug every replicated system meets

This is the heart of the lesson. The familiar scenario: a user changes their avatar — the write goes to the primary. The page reloads — the read goes to a replica. If the replica has not applied the change yet, they see the old avatar and conclude the system is broken. That is a read-your-writes violation: you must always be able to read back what you yourself just wrote.

The figure below names the two intervals that decide this, and both symbols are used for the rest of the section: Δ (delta) is the replication lag — how long the replica needs to finish applying a change that just happened on the primary. ε (epsilon) is the gap between that same user's write and their next read. If ε is larger than Δ the replica has caught up and the user sees fresh data; if ε is smaller than Δ they see stale data. That is all there is to it — and note that both are intervals, so what decides the outcome is the ratio between them, not whether Δ is large or small.

The lag only has to be larger than the gap between THAT SAME user's write and read t t₀ WRITE to the primary version 5 → 6 t₀+ε READ from the replica → returns version 5 the user sees the OLD avatar t₀+Δ replica finishes applying → only from here is version 6 visible ε Δ = replication lag every read inside this band returns stale data Measured for real: Δ = 0.557 ms — but because ε is smaller still (the app writes then reads immediately), 87.21% of reads land in the red band.
The common misunderstanding: this bug does not require a "large" lag. It only requires ε < Δ, and inside a web request ε is usually measured in microseconds.

The lab has a /rww endpoint that does exactly two things: write to the primary (using RETURNING version so it knows precisely which version it wrote), then immediately read from the replica. If the version read back is lower than the version just written, that is stale data — measurable, not a matter of impression. This is the smallest ε possible: the two statements sit right next to each other inside one request, with nothing in between. The three counters rwwPinned, rwwTotal and rwwStale are exactly where the percentages in the table below come from:

app/app.js — handler /rww
// 1) WRITE to the primary. RETURNING tells us exactly which version we just wrote.
const wrote = await pgPrimary.query(
  `UPDATE profiles SET avatar = 'avatar-v' || (version + 1) || '.png',
                       version = version + 1, updated_at = now()
   WHERE id = ${id} RETURNING version`
);
const wroteVersion = Number(wrote[0] && wrote[0].version);
if (usePin && READ_PIN_MS > 0) readPin.set(id, Date.now() + READ_PIN_MS);

// 2) READ IMMEDIATELY. This is the decisive moment: the replica may not have caught up.
const pinned = usePin && shouldReadPrimary(id, Date.now());
if (pinned) rwwPinned++;
const target = pinned ? pgPrimary : pgReplica;
const read = await target.query(`SELECT version, avatar FROM profiles WHERE id = ${id}`);
const readVersion = Number(read[0] && read[0].version);

rwwTotal++;
// "Stale" is MEASURABLE, not a feeling: reading a version LOWER than the one we
// just wrote is not open to argument.
const stale = readVersion < wroteVersion;
if (stale) rwwStale++;

Four configurations, each with 16 connections for 12 seconds, about 100,000 write-then-read rounds:

Configuration replay_lag Stale-read rate Throughput p99
A · natural lag, no pinning 0.557 ms 87.21% (90,376/103,627) 7,547 rps 4.71 ms
B · lag forced to 200 ms, no pinning 200.3 ms 100.00% (102,625/102,625) 7,516 rps 4.88 ms
C · lag 200 ms, pinned to primary for 500 ms 200.2 ms 0.00% (0/98,358) 7,200 rps 5.16 ms
D · lag 200 ms, pinning disabled per request 200.9 ms 100.00% 7,295 rps 5.10 ms

Row A is the one worth stopping on. A lag of 0.557 ms — a number no dashboard would alert on, and many people would call "essentially no lag" — still made 87% of reads return stale data. Because the app writes and then reads inside the same request, the gap ε between the two statements is only a few tens of microseconds. Against that ε, half a millisecond is a very long time.

Row C shows the fix working absolutely: 0 out of 98,358. And the price is surprisingly cheap — throughput falls from 7,547 to 7,200, which is 4.6%. Row D is the control: the same configuration but with pinning disabled per request sends the rate straight back to 100%, proving it is the pinning mechanism itself that made the difference and not some other incidental change.

The second fix: let the database handle it

There is another way that needs no application code changed at all: set synchronous_commit = remote_apply. The primary then answers "write committed" only after the replica has finished applying it, so every subsequent read on the replica sees fresh data. Measured for real, at natural lag, with no pinning whatsoever:

Fix Stale-read rate /rww throughput What it really costs
No fix 87.21% 7,547 rps
Pin reads to the primary (application layer) 0.00% 7,200 rps (−4.6%) More code and more state; only correct for the person who just wrote
remote_apply (database layer) 0.00% 6,594 rps (−12.6%) Every write gets slower, including for people who never read back; pure writes −42% tps

Both fixes give 0%, but they pay for it in two different places. Pinning at the application layer only slows down the reads of someone who just wrote — a very small share of traffic. remote_apply slows down every write in the system, including those of people who never read anything back. That is why the application-layer fix is the more common one, even though it means writing code.

🔬 The limits of the pin table, and why it should not be a Map in RAM
In the lab the pin table is a Map in the app process's memory. It gives 0% because /rww writes and reads inside the same request, so it is always the same process.

A real system is not like that: the user writes (the request lands on app1) then reloads the page (the request lands on app2). app2 knows nothing about that write and reads the replica anyway — the bug is back. And it comes back in the most annoying way possible: it only happens to a fraction of users, it cannot be reproduced on demand, and it disappears when you try it on a dev machine with a single app replica. Same family as the local-state bug in Lesson 3 section 3.5.

Three ways to make the pin state shared, from simple to strict: (1) put a flag in the user's own cookie or token — needs no extra infrastructure, and it naturally follows the right person; (2) store it in Redis (already present since Lesson 5); (3) strictest of all, pass around the LSN — the position in the WAL — that the user needs to see, then only read from a replica that has replayed up to that position, comparing with pg_last_wal_replay_lsn(). Option (3) needs no time window and no guessing, but it does require carrying the LSN through the API layer.
⚠️ Pitfall: spreading reads across replicas with different lag
You have three replicas and round-robin your reads across all three. Replica A is 5 ms behind, replica B is 400 ms behind. The user hits F5 twice: the first time they land on A and see fresh data, the second time they land on B and see stale data. The data jumps forward and then back.

This is a monotonic reads violation, and it is the worst kind of bug to deal with: the user reports "it keeps jumping around", you try it ten times and it is fine every time, and no log records anything because no request failed. The usual fix is to pin each session to a fixed replica — that guarantees nothing about freshness, but it does guarantee the data never goes backwards. For a user, "a bit stale but stable" is far easier to accept than "jumping back and forth".

Measuring lag correctly

PostgreSQL exposes three different kinds of lag, and only one of the three is the number a reader actually feels. The lab's tools/pg-lag.sh script asks the primary directly; here is its query portion, quoted verbatim:

tools/pg-lag.sh — the part that queries the primary
# write_lag   time until the replica has WRITTEN the WAL to disk
# flush_lag   time until the replica has FLUSHED it (durable)
# replay_lag  time until the replica has APPLIED it ← THIS is what a reader sees
docker compose exec -T postgres psql -U lab -d lab -x -c "
  SELECT application_name,
         state,
         sync_state,
         pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn))   AS unsent,
         pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS unreplayed,
         COALESCE(write_lag::text,  '-') AS write_lag,
         COALESCE(flush_lag::text,  '-') AS flush_lag,
         COALESCE(replay_lag::text, '-') AS replay_lag
  FROM pg_stat_replication;" 2>/dev/null

Two columns worth noting: unsent is how much WAL the primary has not sent yet, and unreplayed is how much WAL the replica has received but not applied. The three lag columns behind them are wrapped in COALESCE because PostgreSQL returns NULL when there has been no transaction to measure — and NULL here means "not known", not "zero".

⚡ Do not alert on pg_last_xact_replay_timestamp() while the system is idle
The most commonly passed-around way to measure lag is now() - pg_last_xact_replay_timestamp() on the replica. The problem: when there is no write traffic, that number grows steadily over time even though the replica has caught up perfectly — because it measures "how long since the last transaction was replayed", not "how far behind are we".

The practical consequence: an alert built on that formula fires at 3 in the morning, exactly when the system is quietest and nothing is wrong. In the lab this measured 0.944 seconds while the real replay_lag was 0.000465 seconds — two thousand times apart.

The right way: alert on replay_lag from pg_stat_replication on the primary side, or on the LSN distance (unreplayed in the script above). Both are zero when the replica has genuinely caught up.

7.4 Failover and split-brain — building the disaster by hand

When the primary dies, a replica has to be promoted to become the new primary. It sounds simple, and PostgreSQL does it with exactly one statement. The problem is not the promoting, it is the question: how do you know for certain that the old primary really is dead?

The lab lets you reproduce the worst case exactly. We cut the network between the replica and the primary — simulating a partition, with the primary still alive and still accepting writes — and then promote the replica with no mechanism guaranteeing the old primary has stopped:

cause_split_brain.sh
cd blog/sysdesign/sysdesign-lab

# Before: the replica is a standby and REFUSES every write
docker compose exec -T postgres-replica \
  psql -U lab -d lab -tAc "SELECT pg_is_in_recovery();"
# t

# 1) CUT THE NETWORK between replica and primary (primary is STILL ALIVE, still writing)
docker network disconnect sysdesign-lab_default sysdesign-lab-postgres-replica-1

# 2) PROMOTE the replica — no fencing, nobody confirmed the old primary is dead
docker compose exec -T postgres-replica \
  psql -U lab -d lab -tAc "SELECT pg_promote(wait => true);"
docker compose exec -T postgres-replica \
  psql -U lab -d lab -tAc "SELECT pg_is_in_recovery();"
# f        ← it is a primary now

# 3) BOTH now accept writes for the SAME row
docker compose exec -T postgres psql -U lab -d lab -tAc \
  "UPDATE profiles SET avatar='tu-PRIMARY-CU.png', version=version+100
   WHERE id=500 RETURNING version, avatar;"
# 762|tu-PRIMARY-CU.png
docker compose exec -T postgres-replica psql -U lab -d lab -tAc \
  "UPDATE profiles SET avatar='tu-PRIMARY-MOI.png', version=version+200
   WHERE id=500 RETURNING version, avatar;"
# 862|tu-PRIMARY-MOI.png

# 4) Reconnect the network. Can it reconcile itself?
docker network connect sysdesign-lab_default sysdesign-lab-postgres-replica-1
docker compose logs postgres-replica | tail -4
# LOG:  selected new timeline ID: 2
# LOG:  archive recovery complete
# LOG:  database system is ready to accept connections

The result: for the same row id=500, the old primary holds 762 | tu-PRIMARY-CU.png and the new one holds 862 | tu-PRIMARY-MOI.png (those two strings are just labels the write statements set — Vietnamese for "from-OLD-PRIMARY" and "from-NEW-PRIMARY" — so you can tell at a glance which node a row came from). The data has diverged. And reconnecting the network reconciles nothing — the log line selected new timeline ID: 2 is PostgreSQL itself telling you that history has forked in two.

Split-brain — and why there is no way to merge automatically a single history · timeline 1 PARTITION the network is cut OLD PRIMARY — still alive, still accepting writes id=500 → 762 | tu-PRIMARY-CU.png NEW PRIMARY — just promoted · timeline 2 id=500 → 862 | tu-PRIMARY-MOI.png re- connect Why it cannot be merged Both branches are VALID writes, already committed and already answered "success" to the client. No rule says which branch is right — one branch must be THROWN AWAY. Prevention: FENCING before promoting Only promote once you are CERTAIN the old primary cannot write: power it off, revoke its write privilege, or block it at the network layer. And a quorum/witness must decide — never let each node decide for itself. The key point: a partition tells NEITHER node whether the other has died or has merely lost contact. From the inside, those two situations are completely identical. This is exactly the problem Lesson 9 (CAP) formalises: when a partition happens, you must choose between consistency and availability.
This entire situation is reproduced with two docker network commands and one pg_promote — worth doing once, to see how easily it happens.
⚠️ Pitfall: automatic failover without fencing
This is precisely the experiment above, and it is the default setup in a great many "build PostgreSQL HA in 10 minutes" guides. The monitoring sees the primary not responding, so it promotes the replica. But "not responding" may just be a lost network link — the old primary is still alive and still accepting writes from whichever clients can still reach it.

Once the network recovers you have two sets of data, both valid, with no way to reconcile them automatically. One side's data has to be thrown away, and during the divergence clients on both sides were told "success".

That is why every trustworthy failover system has three things: fencing (making sure the old node cannot write), quorum (a majority decides who is primary, rather than each node deciding for itself), and a witness (a third node whose only job is to break a 1–1 tie). Without fencing, the other two cannot save you either.

7.5 Operating it: what to watch, and the classic conflict

The replica is the most easily forgotten component in a system, because when it works nobody sees it, and when it falls behind the symptom shows up somewhere else entirely — users report "wrong data", nobody reports "the replica is slow".

Watch Why Start alerting at
replay_lag, per replica This is the number a reader actually feels When it exceeds the threshold your application logic assumes (the pin window, for instance)
Unreplayed LSN distance It does not suffer the "grows while idle" artefact that the time-based measure does Any value that climbs continuously without coming back down
Row count in pg_stat_replication A disconnected replica vanishes from this view entirely — complete silence Fewer than the number of replicas you think you have
WAL still retained on the primary If a replica falls too far behind and the WAL has been deleted, it has to pg_basebackup from scratch — that is, copy the entire data directory from the primary again, because there is no longer enough journal left to catch up by replaying; hours on a large database When WAL volume approaches wal_keep_size
Do NOT alert on the replica's CPU Replaying WAL is a single-threaded process: a replica can fall a very long way behind while total CPU still looks perfectly idle
⚠️ Pitfall: running reports on a replica that is serving users
This is the classic conflict, and it is very tempting: you already have a replica, so let the analytics team run their reports against it instead of the primary — it sounds like efficient use of resources.

The problem: an analytical query scanning a whole table competes for I/O and memory with the WAL replay process, making lag balloon. And ballooning lag means the user-facing read path starts returning stale data — an internal report has just broken the customer experience. Worse, PostgreSQL may also have to cancel that very query (conflict with recovery) or postpone replay to keep it running, depending on hot_standby_feedback and max_standby_streaming_delay.

The right answer is almost always a separate replica for analytics, outside the pool serving users. It costs more money, and that is the entire content of this decision: you are buying separation between two kinds of load with completely different requirements.

Reproduce the measurements yourself

reproduce_measurements.sh
cd blog/sysdesign/sysdesign-lab
docker compose --profile replica up -d   # real primary + replica, streaming replication

# Check that replication is actually running
./tools/pg-lag.sh
docker compose exec -T postgres-replica \
  psql -U lab -d lab -tAc "SELECT count(*) FROM profiles;"                    # 1000
docker compose exec -T postgres-replica \
  psql -U lab -d lab -tAc "UPDATE profiles SET version=99 WHERE id=1;"
# ERROR: cannot execute UPDATE in a read-only transaction  ← correct, replica is read-only

# --- A: stale-read rate at the NATURAL lag ---
./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js \
  --url "http://lb:8080/rww" -c 16 -d 12 -w 2 --json
./tools/cache-stats.sh          # look at the READ-YOUR-WRITES line

# --- B: force a CONTROLLED 200ms lag, simulating a replica in another region ---
docker compose exec -T postgres-replica psql -U lab -d lab \
  -c "ALTER SYSTEM SET recovery_min_apply_delay = '200ms';" -c "SELECT pg_reload_conf();"
./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js \
  --url "http://lb:8080/rww" -c 16 -d 12 -w 2 --json
./tools/cache-stats.sh          # 100%

# --- C: turn the pin-to-primary mechanism on ---
# set READ_PIN_MS to '500' for app1/app2/app3 in docker-compose.yml, then:
docker compose --profile replica up -d --force-recreate app1 app2 app3
./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js \
  --url "http://lb:8080/rww" -c 16 -d 12 -w 2 --json
./tools/cache-stats.sh          # 0%

# --- D: control run. Same pinned setup, but ?pin=0 disables the pin per request,
#        which proves the pin is what made the difference and not something else.
./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js \
  --url "http://lb:8080/rww?pin=0" -c 16 -d 12 -w 2 --json
./tools/cache-stats.sh          # back to 100%

# Restore the default
docker compose exec -T postgres-replica psql -U lab -d lab \
  -c "ALTER SYSTEM SET recovery_min_apply_delay = '0';" -c "SELECT pg_reload_conf();"
ℹ️ After the split-brain experiment, the lab needs rebuilding
Once you promote the replica it becomes a primary on a new timeline and cannot go back to being a standby of the old primary. The cleanest way to rebuild is to delete the data so the replica pg_basebackups itself from scratch:

docker compose --profile replica down -v then docker compose --profile replica up -d.

The -v flag deletes the volumes — which means deleting the primary's data as well. In the lab that is fine because the schema is reloaded automatically from postgres/init/, but remember it is a destructive command.

In summary

A replica serves three different purposes that need three different configurations, and a replica is not a backup — in the lab you get 0.557 ms to regret a bad delete. Choosing a synchronisation level is choosing a point on the durability ↔ write-latency axis: going from async to sync, write latency rose 79% and write throughput fell 44%.

The biggest lesson is row A of the table in section 7.3. A replication lag of 0.557 ms — a number every dashboard would show in green — still broke read-your-writes on 87% of reads. Because what decides the outcome is not whether the lag is large or small, but whether it is larger than the gap between that same user's write and their read. Inside a web request, that gap is measured in microseconds.

Both fixes give exactly 0%: pinning reads to the primary (application layer, −4.6% throughput) and synchronous_commit = remote_apply (database layer, −12.6%). They differ in where they pay: the application-layer fix only slows down the person who just wrote, while the database-layer fix slows down every write in the system.

Lesson 8 asks the question replicas cannot answer: what happens when the writes themselves exceed what one machine can take, or when the data no longer fits on one disk. That is when you have to shard — the most expensive step in the whole series in terms of complexity, and also the one people reach for too early most often.

📖 References

Download the lab source

The two files this lesson reads from directly: the hand-written, dependency-free PostgreSQL client (the app uses it to talk to both the primary and the replica), and the lag-measuring script quoted verbatim in "Measuring lag correctly":

Download minipg.js (0-dependency PostgreSQL client) Download pg-lag.sh

Related lessons in this series

Lesson 6: CDN & Edge Caching Lesson 8: Sharding & Consistent Hashing Back to the System Design roadmap

Comments