In this lab you'll add read replicas to Tiffin for real: decide which reads move, how many replicas you need, how to stop customers seeing missing or stale orders, and how to roll it out without a bad Friday. For each step, decide first, then compare with one way of reasoning it through.
The brief
Tiffin's database team has done the cheap fixes from this module: queries are indexed, menus are cached in Redis, PgBouncer pools connections, and the primary has been scaled up to 64 cores. A synchronous standby in a second zone handles failover.
It's not enough for long. At Friday dinner, the primary sits at 62% CPU, and traffic grows about 8% a month. The goal for this project:
- Get the primary under 40% CPU at dinner, with room to grow for a year.
- No customer ever sees an order they just placed go missing, and order status never goes backwards.
- Losing any one replica doesn't hurt customers.
Here's where the primary's CPU goes at peak, from pg_stat_statements and request tracing:
| Workload | Queries/s at peak | Share of primary CPU |
|---|---|---|
| A. Menu reads (Redis cache misses) | 900 | 8% |
| B. Restaurant list and search | 1,800 | 14% |
| C. Reviews and ratings | 600 | 4% |
| D. Order status (about 10,000 open order screens, each polling every 10 s) | 1,000 | 4% |
| E. My orders and order details | 400 | 6% |
| F. Kitchen tablet poll: 3,000 tablets every 5 s, two queries each | 1,200 | 7% |
| G. Checkout reads: cart, prices, stock, promo | 300 | 3% |
| H. All writes: orders, status changes, rider locations | 1,500 | 14% |
| I. Finance dashboard | a few per minute | 2% |
| Total | 62% |
Step 1: Which reads can move?
Sort the workloads
For each read workload A to G and I, decide: replica, replica with extra care, or primary. Then estimate the primary's CPU afterwards.
Show one answer
| Workload | Decision | Reasoning |
|---|---|---|
| A. Menus | Replica | A dish change seconds late is harmless. Most of it is cached anyway. |
| B. Restaurants and search | Replica | Nobody can tell a second of staleness. |
| C. Reviews | Replica | Same. |
| D. Order status | Replica, with care | Status changes are written by restaurants and riders, and being a second late is fine. But the customer polls a brand-new order straight after placing it. Needs read-your-writes handling (Step 3). |
| E. My orders | Primary | The customer's own data, read right after they write it. Small load. |
| F. Kitchen poll | Replica, with care | New orders written by customers arriving a fraction of a second late is fine, since the tablet only polls every 5 s anyway. But after the tablet accepts an order, it must see its own change. |
| G. Checkout | Primary | Prices, stock and promo counts must be current. |
| I. Finance | Separate reporting replica | Keep heavy queries away from customer replicas. |
Primary afterwards: writes 14% + my orders 6% + checkout 3% + a little for fallbacks ≈ 24%. That leaves room for writes to grow for well over a year.
Moving to customer replicas: A + B + C + D + F = 8 + 14 + 4 + 4 + 7 = 37% of a primary's worth of CPU.
Notice the default. Workload E could technically go to a replica with read-your-writes tricks, but it's small, so keeping it on the primary buys simplicity cheaply. Move the big, easy loads first. Don't spend complexity on the small ones.
Step 2: How many replicas?
Size the replica pool
Replicas are the same size as the primary. Each one also spends about 2% CPU replaying writes (one core of 64, since Postgres replays in a single process). The reads you're moving are 37% of a primary's worth. Reads grow 8% a month. How many replicas, and why? Keep each replica under 50% even if one of them fails.
Show one answer
Two replicas: 37 / 2 + 2 = 20.5% each. If one fails, the other takes 37 + 2 = 39%. Under the line on day one, and over it after about three months of growth.
Three replicas: 37 / 3 + 2 = 14% each. If one fails, the other two take 18.5 + 2 = 20.5% each. Comfortable.
Growth: 8% a month doubles traffic in about 9 months (1.08^9 ≈ 2). By then reads are 74% of a primary's worth and replay work has doubled too, so three replicas run at about 29% each, and about 41% each if one fails. The one-failed case crosses 50% around month 11, so plan to add a fourth around month 10, rather than at the last minute.
Put one replica in each of three zones. Then losing a whole zone costs one replica, which the design already survives.
Plus one more for finance: a smaller, separate reporting replica. Its queries can take minutes, and you can let it fall behind during them (by allowing longer replay delays) without any customer noticing.
Step 3: Read-your-writes and monotonic reads
This is the step that decides whether the project succeeds or causes the "my order vanished" tickets from the replication lag lesson.
Design the consistency rules
Work out what to do for these three cases:
- A customer places an order, and their app immediately starts polling
GET /orders/:id/status. - A customer's app polls status every 10 seconds. With three replicas at different lag, status could appear to go backwards.
- A kitchen tablet taps "Accept" and then refreshes its order list.
Show one answer
1. New order, immediate poll. First, the app shows the order from the POST /orders response, so there's no empty screen. Then, in the status endpoint, if the replica says "not found", check the primary before answering. An order that doesn't exist yet on a replica is the only case where this fallback runs, so it adds very little primary load:
async function getOrderStatus(orderId, customerId) {
const db = replicaFor(customerId);
let row = await db.oneOrNone('SELECT status, updated_at FROM orders WHERE id = $1', [orderId]);
if (!row) {
// just placed: this replica may not have it yet
row = await primary.oneOrNone('SELECT status, updated_at FROM orders WHERE id = $1', [orderId]);
}
return row;
}
2. Status going backwards. replicaFor(customerId) hashes the customer ID to pick one replica, so each customer always reads from the same one. A single replica only ever moves forward, so status can't go from "Out for delivery" back to "Preparing". If that replica is removed, the customer moves to another, and the rare small jump is acceptable.
3. Tablet after accepting. The tablet moves the order into the "accepted" list immediately, using the response to its own request. For its next reads, the server stores the primary's log position (LSN) after the accept and only serves that tablet from a replica once the replica has replayed that far. Otherwise it reads from the primary. Tablets accept a few orders a minute each, so this rarely touches the primary.
Case 1 as a request flow, with the fallback that saves the customer from an empty screen:
%% caption: A brand-new order isn't on the replica yet, so the status read falls back to the primary once. sequenceDiagram autonumber participant C as Customer app participant S as App server participant R as Pinned replica participant P as Primary C->>S: POST /orders S->>P: insert the order S-->>C: the order, shown at once C->>S: GET /orders/:id/status S->>R: read the status R-->>S: not found yet S->>P: read the status P-->>S: placed S-->>C: placed
Write these rules down where every engineer will see them. The most common way this design breaks isn't the rules being wrong. It's a new endpoint added six months later that reads from a replica without anyone thinking about lag. That's why the default for new code is the primary.
Step 4: Guardrails for lag
When lag spikes
On a Wednesday afternoon, a data backfill makes one replica fall 40 seconds behind. What should happen automatically, and what if all three replicas fall behind at once?
Show one answer
One replica behind: its health check includes lag. Above 5 seconds it's marked unhealthy and removed from rotation. Its customers are re-hashed to the other two, which have plenty of room. When it catches up, it comes back. An alert fires when any replica passes 1 second for more than a minute.
All replicas behind: don't blindly send everything to the primary. 24% + 37% = 61% is survivable today, but not in six months, and certainly not at 8pm. Instead: keep serving menus, search and reviews from replicas and cache (stale by a minute is fine for those), and move only the consistency-sensitive reads (order status, kitchen polls) to the primary.
Prevention: run backfills in small batches with pauses, watching lag, and never during dinner.
Step 5: Roll it out
Plan the rollout
You could switch all five workloads to replicas in one deploy. Should you? If not, in what order, and what do you watch?
Show one answer
No. Move one workload at a time, each behind a feature flag, starting with the least risky:
- Reviews (C), then restaurant list and search (B): no consistency risk, and B is a big chunk of load.
- Menus (A): low risk.
- Kitchen poll (F): needs the LSN check working. Start with 5% of restaurants.
- Order status (D): the riskiest. Start with 5% of customers for a few days.
Watch: replica lag and CPU, primary CPU, the rate of "order not found" responses, how often the primary fallback runs, and support tickets mentioning missing or wrong orders. Leave a week between the last two steps, including a Friday dinner.
Step 6: Failover and backups
Adding replicas changes two things you already had working.
What else needs updating?
The synchronous standby still handles failover. What happens to the three read replicas and the reporting replica when the primary fails over? And should backups change?
Show one answer
Failover: after the standby is promoted, every replica must start following the new primary. The failover tool (such as Patroni) should do this automatically, and the monthly failover drill should now check that all four replicas reconnect and that their lag returns to normal. Keep the standby free of read traffic, so it's always ready and never overloaded at the moment it gets promoted.
Backups: still essential, since replicas copy mistakes. One improvement: take base backups from a replica rather than the primary, so the large file copy doesn't compete with dinner traffic. Tools like pgBackRest support this. WAL archiving continues as before. The weekly restore test stays.
The final design
%% caption: Tiffin after the project. Customer reads spread over three replicas, one per zone, each about 14% CPU at dinner.
flowchart TD
A["App servers"] --> PB["PgBouncer primary"] --> P[("Primary<br/>writes, checkout, my orders, ~24%")]:::hot
A -- "customer reads" --> RS[("Replicas 1, 2, 3<br/>zones A, B and C")]:::store
P -. "replicates" .-> RS
P -.-> SB[("Sync standby<br/>zone B, failover only")]:::store
P -.-> RR[("Reporting replica<br/>finance only")]:::storeHow the app chooses:
default for new code: primary
menus, search, reviews: replicas, any of the three
order status, kitchen polls: the customer's or restaurant's pinned replica,
with primary fallback for new orders and LSN check after writes
checkout, my orders, writes: primary
finance: reporting replica
replica lag > 5 s: removed from rotation
And the results: the primary drops from 62% to about 24% at dinner, the replicas run at about 14% each, any one replica or a whole zone can fail without customers noticing, and nobody's order vanishes.
Build it on AWS
Tiffin's database is RDS for PostgreSQL, and the "synchronous standby in a second zone" is simply RDS Multi-AZ, which Tiffin has had since launch (see the first version lab). Read replicas are a few clicks on top. One change since the dinner rush lab: when the team scaled up to 64 cores, they also moved from the general-purpose m7g family to the memory-optimised r7g, which has twice the memory per vCPU, so more of the working set stays in cache. The interesting parts are the ones the console doesn't tell you about: what this costs, which metric lies at 4am, and why the managed connection pooler can't pool replicas. Prices are Mumbai (ap-south-1) on-demand list prices as of late 2026; check the AWS pricing calculator before quoting them.
The architecture
%% caption: The replica project on RDS. Writes through RDS Proxy; pinned customer reads through PgBouncer to three replicas.
flowchart TD
T["ECS tasks"] -- "writes, checkout" --> RP["RDS Proxy"] --> P[("Primary, Multi-AZ<br/>db.r7g.16xlarge")]:::hot
T -- "pinned customer reads" --> PB["PgBouncer on ECS"] --> RS[("3 read replicas<br/>one per zone")]:::store
P -. "asynchronous" .-> RS
P -.-> RR[("Reporting replica<br/>db.r7g.4xlarge")]:::store
HB["Heartbeat writer"] -.-> P| Generic box | On AWS | The setting that matters |
|---|---|---|
| Primary plus synchronous standby | RDS for PostgreSQL Multi-AZ, db.r7g.16xlarge (64 vCPU, 512 GiB) |
The standby can't serve reads. It only waits to take over |
| Replicas 1 to 3 | RDS read replicas of the same class, one per Availability Zone in Mumbai | Each has its own endpoint; RDS doesn't balance across them or add more for you |
| Reporting replica | A smaller read replica, db.r7g.4xlarge |
Allowed to fall behind during long finance queries |
| PgBouncer | RDS Proxy for the primary, PgBouncer on ECS for the replicas | See Argument 3 |
| Lag check | A heartbeat row written every second, compared on each replica | See Argument 2 |
Argument 1: what three full copies cost, and the other shapes
Replicas are billed as ordinary database instances, while the replication traffic itself is free within a Region:
Primary, Multi-AZ, db.r7g.16xlarge: $17.408/hour = ~$12,700 a month
3 replicas, same class: 3 x $8.704/hour = ~$19,100
Reporting replica, r7g.4xlarge: $2.176/hour = ~$1,600
Storage, 800 GB gp3: Multi-AZ primary plus 4 replicas = ~$630
More than half the database bill is now replicas, because RDS read scaling means copying the whole database onto more machines. AWS sells two other shapes, and both are worth knowing even though this project doesn't pick them:
- A Multi-AZ DB cluster has two readable standbys behind a reader endpoint. That gives fewer readers than the three this lab needs, and the readers are also the failover targets, which is exactly what Step 6 chose to avoid ("keep the standby free of read traffic").
- Aurora PostgreSQL keeps one shared storage volume under all its replicas, so a replica doesn't need its own full copy on its own disk, and a reader endpoint spreads connections. It changes cost and failover more than any setting here. The course moves Tiffin to Aurora in Module 9, when storage growth forces the question. For this project, RDS replicas are a configuration change, not a migration, and that's the right size of risk.
Argument 2: the lag guardrail that fires at 4am
Step 4 removes a replica from rotation when its lag passes 5 seconds. The obvious way to measure lag on RDS is the ReplicaLag metric in CloudWatch. It has a trap that AWS documents: for PostgreSQL, the value is "now minus the time of the last replayed commit". So when the primary commits nothing, which can happen at 4am, the reported lag climbs for up to five minutes until the next log segment switches, even though the replica is fully caught up. A rule of "remove above 5 seconds" built on that metric evicts every replica overnight and pages someone about it.
The fix is to give replicas something to replay. Run a tiny heartbeat job that updates one row on the primary every second with the current time. Each replica's lag is then "now minus the heartbeat it can see", which is honest whether Tiffin is busy or asleep. For read-your-writes, the LSN check in Step 3 still uses PostgreSQL's own functions (pg_last_wal_replay_lsn() on the replica), which work unchanged on RDS.
There's no reader endpoint for plain RDS replicas either, so replicaFor(customerId) hashes over a list of replica endpoints kept in configuration. Removing a lagging replica means taking it out of that list (or out of PgBouncer's pool), and the customers pinned to it re-hash to the others, exactly as Step 4 planned.
Argument 3: who pools the replica connections?
RDS Proxy is the managed answer to PgBouncer, and it's good at the primary's problem: it holds a small pool of database connections for hundreds of app processes, and it handles failover without each task reconnecting on its own. In Mumbai it costs $0.017 per vCPU-hour of the database it fronts, about $800 a month for a 64-vCPU primary.
But it doesn't cover this lab's replicas. AWS documents that for RDS instances in a replication setup, a proxy can attach only to the writer, not to a read replica. Reader endpoints exist only for Aurora and Multi-AZ DB clusters. So the split is:
- Primary: RDS Proxy. Checkout and writes are where connection storms and failovers hurt most, and someone else runs it.
- Replicas: PgBouncer, which the team already runs, as a small ECS service with one pool per replica. It costs a few tens of dollars a month in tasks, plus some care when a replica is removed from the list.
One more number: tasks reading from replicas in other zones pay for cross-zone data transfer, $0.01 per GB each way. The moved reads are 5,500 a second at the dinner peak; averaged over the day, say about 2,200 a second. At about 2 KB per response that's roughly 11 TB a month, two thirds of it crossing zones, which is about $150 a month. That's not worth giving up the customer-pinned design for zone affinity.
Argument 4: failover drills and backups, re-read for RDS
Two tools from Step 6 go away. Patroni isn't needed, because RDS runs the failover: it promotes the standby and moves the database's DNS name, typically within 60 to 120 seconds. The monthly drill becomes "reboot with failover" on the primary, at a quiet hour, then checking that every replica's heartbeat lag returns to normal and that the app reconnected through RDS Proxy.
pgBackRest isn't needed either. RDS takes the automated backups, and Multi-AZ exists partly so that backups don't cause latency spikes on the primary. Point-in-time restore works on the primary, not on read replicas, so the weekly restore test restores the primary to a new instance, as before. One quota to watch: Mumbai's default limit is 20 RDS instances per account. This project uses five, but staging copies and restore tests count too.
What it costs
| Line | Monthly, roughly |
|---|---|
Primary, Multi-AZ db.r7g.16xlarge |
~$12,700 |
| Three read replicas, same class | ~$19,100 |
Reporting replica db.r7g.4xlarge |
~$1,600 |
| Storage, 800 GB on five instances | ~$630 |
| RDS Proxy for the primary | ~$800 |
| Cross-zone reads | ~$150 |
| Total | ~$35,000 |
At about 200,000 orders a day, that's roughly 0.6 cents per order. Replicas are the biggest line, which is the subject of the exercise.
The same design on GCP and Azure
| Role | AWS | Google Cloud | Azure |
|---|---|---|---|
| Managed PostgreSQL with a standby | RDS Multi-AZ | Cloud SQL with high availability | Azure Database for PostgreSQL, zone-redundant |
| Read replicas | RDS read replicas | Cloud SQL read replicas | Read replicas |
| Shared-storage PostgreSQL | Aurora PostgreSQL | AlloyDB | No direct equivalent |
The traps carry over: check how each platform reports replica lag on an idle database, and whether its managed pooler can reach replicas.
Can the replicas be cheaper?
The three replicas are each db.r7g.16xlarge, matching the primary, and they cost about $19,100 a month. Each runs at about 14% CPU at dinner. Propose a cheaper replica setup that still keeps every replica under 50% CPU if one fails, and say what you'd watch.
Show one answer
Use four db.r7g.8xlarge replicas (32 vCPU each) instead of three 16xlarge ones. Four at $4.352 an hour cost about $12,700 a month, saving about $6,400.
Check the CPU with the lab's own numbers. The moved reads are 37% of a 64-core primary, about 24 cores, and replay costs about 1 core per replica:
Four replicas: 24 / 4 + 1 = 7 cores of 32 = ~22% each
One fails: 24 / 3 + 1 = 9 cores of 32 = ~28% each
Both are under 50%, and losing one replica now costs a quarter of the read capacity instead of a third.
What to watch: memory. Each replica drops from 512 GiB to 256 GiB, so if the hot working set doesn't fit, cache hit rates fall and disk reads rise. Check the buffer cache hit ratio during a rollout on one replica first. And growth: at 8% a month, reads and replay work double in about nine months, which would push the one-failed case to about 56%. It crosses 50% around month seven, so plan the fifth replica then. RDS allows up to 15 replicas per primary, so there's plenty of room.
What to remember
- Start from a breakdown of the primary's load by workload, so you know what moving each one buys.
- Move big, low-risk reads first. Keep small or consistency-sensitive reads on the primary when it's cheap to.
- Size replicas for N+1 and growth, and spread them across zones.
- Design read-your-writes and monotonic reads per workload: use write responses, primary fallbacks, pinned replicas and LSN checks.
- Remove lagging replicas automatically, and don't let a lag spike dump all reads on the primary.
- Roll out one workload at a time behind flags, and update failover drills and backups for the new replicas.
- On RDS, replicas are full copies billed as instances, and here they're over half the database bill. Size them deliberately: several smaller replicas can be cheaper and fail more gracefully than a few huge ones.
- Know what your managed service measures and covers. RDS PostgreSQL's
ReplicaLagclimbs on an idle primary, so use a heartbeat. RDS Proxy doesn't pool plain read replicas.