Module 05 · Lesson 2 of 12Free
Read replicas and replication lag
- Level
- Intermediate
- Topic
- Database scaling: read replicas, sharding and partitioning
- Reading time
- 18 min
When you finish this page you can
- Trace a committed change from the primary database to a readable replica.
- Explain why a successful write can be followed by an older read.
- Choose read routes from product freshness requirements.
- Calculate whether a replica can recover from a replication backlog.
Builds on 2 lessons
Start with lesson 118 minVideo lesson · coming soon
The saved price disappears
A seller on changes a product’s price from ₹1,200 to ₹1,000. The screen says “Saved”. She reloads after 100 ms and sees ₹1,200 again.
The write succeeded. The reload reached a different database that had not applied the change.
How can Kartly move reads off its primary database without making successful writes appear to disappear?
The save response says success. Must a read from any database copy now return ₹1,000?Show answer
Diagram not drawn yet: catalog-read-routing.svg
A seller or shopper calls the catalog service. The service sends writes and fresh reads to the primary database, and ordinary browsing reads to either of two replicas. The primary asynchronously sends changes to both replicas.
We will examine one catalog database group at Kartly’s Part 2 scale. Other services and database groups remain outside this example.
Copies remove reads, not writes
Start with every catalog request using one primary: the database that accepts writes. Before adding machines, check and unnecessary work, as discussed in Scaling a relational database: the options.
A is a separate database copy used to serve read-only queries. Moving suitable queries there reduces work on the primary.2
The simplest application still sends everything to the primary. It has one route and no cross-copy freshness problem. Keep it when measured demand fits comfortably.
For this lesson’s scenario, however, catalog browsing exceeds that capacity.
Estimate primary CPU demand
6,000 reads/s × 0.002 CPU-s/read + 200 writes/s × 0.004 CPU-s/write = 12.8 CPU-s/s
Assume an 8-core primary. These illustrative CPU costs come from our scenario, not a database benchmark.
Move ordinary browsing reads
1,000 reads/s × 0.002 CPU-s/read + 200 writes/s × 0.004 CPU-s/write = 2.8 CPU-s/s
Send the remaining 5,000 reads each second to . Keep writes and freshness-sensitive reads on the primary.
Check each replica’s budget
2,500 reads/s × 0.002 CPU-s/read + 1 CPU-s/s for replay = 6 CPU-s/s
With two 8-core replicas, this assumed workload fits. Measure overhead and other resource limits before accepting the design.
These calculations estimate CPU demand, not maximum . Storage waits, locks and uneven queries can invalidate the estimate.
Should Kartly enlarge the primary or move browsing reads to replicas?
Give the primary more resources.
All reads keep one route. A larger machine may solve the immediate CPU shortage, but every browsing query still competes with writes on that machine.
Add replicas and route suitable reads explicitly.
Browsing work moves away from writes. Kartly pays for extra database copies and must decide which screens may show older data.
Why this choice
Choose replicas here because browsing dominates the measured demand, and its freshness rules permit older results.
Every replica must still process this group’s changes. Replicas therefore do not divide its write workload. If writes become the limit, reconsider the database layout rather than adding more readers.
A larger primary wins when growth is modest or nearly every read needs current data. The choice changes when freshness-sensitive traffic becomes too large for the remaining primary capacity.
The capacity gain depends on moving reads. We now need to understand what those moved reads can observe.
A change travels through stages
For this lesson, use PostgreSQL 18 physical streaming replication. A standby starts from a , receives records, then replays them. A readable standby serves queries from its local state.1
The log mechanism builds on Write-ahead logging: recovering after a crash. Replication sends the record of changes, not another application request to “save this price”.
The following timeline is illustrative:
| Time | Event | What a new read can see |
|---|---|---|
| 0 ms | Primary commits ₹1,000 | Primary: ₹1,000 |
| 10 ms | Seller receives success | Replica: ₹1,200 |
| 40 ms | Replica receives the change records | Replica: still ₹1,200 |
| 100 ms | Reload queries the replica | Replica returns ₹1,200 |
| 300 ms | Replica applies the commit | New replica read: ₹1,000 |
Receiving records is not the same as applying them. In this model, query-visible state changes only after replay reaches the commit.
Diagram not drawn yet: replica-price-visibility.svg
A timeline shows the primary committing a price of ₹1,000 at 0 milliseconds and returning success at 10 milliseconds. The replica receives records at 40 milliseconds, returns the old ₹1,200 price to a read at 100 milliseconds, and applies the commit at 300 milliseconds.
is the replica’s distance behind the primary. You can describe that distance as unapplied log bytes or as a delay in visibility. Those measurements answer different questions.
Do not assume every database uses PostgreSQL’s mechanism. MySQL 8.4 replication records changes in a binary log. A replica copies events into a relay log before processing them.3 The shared lesson is the separation between receiving changes and making them readable.
A replica can therefore be reachable and still be unsuitable for a particular screen.
Success needs an exact meaning
The tempting belief is: “If replication is enabled, a successful write is already visible everywhere.”
Our timeline disproves that belief. The seller received success before the replica applied the commit.
With , the primary does not wait for a replica’s acknowledgment before returning success. PostgreSQL streaming replication uses this mode by default.1
For Kartly, this avoids making every catalog save wait for a remote reader. The cost is the stale-read window we observed.
adds a remote acknowledgment to the commit path. Its meaning matters: PostgreSQL can wait for remote log persistence, or use remote_apply to wait for replay on selected synchronous standbys.1
Persistence protects a different property from read visibility. “The records survived on another machine” does not mean “every reader has applied them”.
Even an apply acknowledgment names particular replicas. It does not make an unrelated asynchronous reader current.
For our catalog scenario, keep asynchronous replication and control read routing. Requiring every browsing replica to participate in writes would make reader problems affect seller saves. If the product instead requires remote persistence before success, revisit the replication acknowledgment policy.
The remaining question belongs to the application: which reads are allowed to use an older copy?
Route by the screen’s promise
Kartly asks three product questions:
| Product question | Scenario answer | Route |
|---|---|---|
| Can a browsing page show an older price? | Briefly, if checkout revalidates it | Eligible replica |
| Can a seller reload an edit and see the previous price? | No | Primary |
| Can checkout approve a purchase using old stock? | No | Primary transaction |
A GET request is not automatically suitable for a replica. The seller reload is read-only, but its promise requires freshness.
Nor does using the primary alone prevent overselling. Checkout must make the stock check and reservation part of one protected operation. That builds on Isolation levels and locking: handling concurrent writes.
How should Kartly make an edited price remain visible to its seller?
Route seller editing workflows to the primary.
The editor always reads from the primary. The rule is easy to inspect, but these reads consume primary capacity.
Send the seller to the primary temporarily.
Reads use the primary for a fixed period after saving. This reduces primary traffic later, but a longer replication delay can outlast the timer.
Track how far a replica must have replayed.
The request carries a required replication position. A replica serves it only after reaching that position; otherwise the request waits or uses the primary. This needs more routing and failure handling.
Why this choice
Choose primary routing because seller editing is a small, identifiable workload. A fixed delay is a heuristic, not a correctness guarantee.
Position-aware routing becomes useful when many reads must see earlier writes and primary capacity is tight. It needs a timeout and a route to use when progress stops.
Use one database connection for the whole transaction. Do not read part of a transaction from a replica and write the rest on the primary.
Keep the rule in one place
The catalog service owns routing. It maintains separate for the primary and each reader.
| Component | Responsibility | Talks to | State it keeps | Failure behavior |
|---|---|---|---|---|
| Catalog service | Classify operations and choose a route | Primary and replicas | Endpoint health and recent observations | Reject optional browsing if no reader qualifies |
| Primary | Accept writes and fresh reads | Catalog service and replicas | Authoritative rows and change log | Writes and fresh reads become unavailable |
| Replica | Apply changes and serve browsing | Primary and catalog service | Local copy and replay progress | Remove it from eligible routes |
Here is the catalog service’s routing policy. The scenario uses a two-second lag threshold and observations no older than one second.
from typing import Literal
Intent = Literal["browse", "fresh", "write"]
Route = Literal["primary", "replica", "unavailable"]
def choose_route(
intent: Intent,
replica_up: bool,
lag_seconds: float | None,
observation_age: float,
) -> Route:
if intent != "browse":
return "primary"
eligible = (
replica_up
and lag_seconds is not None
and 0.0 <= lag_seconds <= 2.0
and 0.0 <= observation_age <= 1.0
)
return "replica" if eligible else "unavailable"
print(choose_route("fresh", True, 0.1, 0.2))type Intent = "browse" | "fresh" | "write";
type Route = "primary" | "replica" | "unavailable";
function chooseRoute(
intent: Intent,
replicaUp: boolean,
lagSeconds: number | null,
observationAge: number,
): Route {
if (intent !== "browse") {
return "primary";
}
const eligible =
replicaUp &&
lagSeconds !== null &&
lagSeconds >= 0 &&
lagSeconds <= 2 &&
observationAge >= 0 &&
observationAge <= 1;
return eligible ? "replica" : "unavailable";
}
console.log(chooseRoute("fresh", true, 0.1, 0.2));The code selects a role, not a database connection. For browsing, evaluate each replica and choose among eligible readers. An unavailable result maps to a temporary browsing error in this design.
This health policy is best effort. A replica can fall behind immediately after observation. It does not prove a strict two-second freshness bound.
Follow one save and reload
- The catalog service validates the seller’s edit.
- It updates the primary and waits for commit.
- It returns the saved representation to the seller.
- Replication independently transfers and applies the change.
- A seller reload still reads the primary.
- Ordinary browsing can use an eligible replica.
Returning the saved representation avoids an unnecessary immediate reread. Primary routing protects later reloads too.
If the save response is lost, reading a replica cannot prove the write failed. Do not repeat a non-repeatable action based on absence there. Safe retry behavior belongs in Idempotency: making retries safe.
Routing now protects the workflow, but the replicas still need enough capacity to stay useful.
A backlog needs spare capacity
Suppose a festival sale increases the primary’s log generation rate. A replica receives changes faster than it can apply them.
A faster network will not solve an apply bottleneck. The pending records already reached the reader; replay needs more processing or storage capacity.
Use log bytes rather than transaction counts for this estimate. Transactions can produce very different amounts of work.
Calculate backlog growth
12 MB/s arriving − 8 MB/s applied = 4 MB/s accumulating
These are illustrative sustained rates for this database group.
Calculate the accumulated backlog
4 MB/s × 60 s = 240 MB
After one minute, the reader has 240 MB more pending work.
Calculate recovery time
240 MB ÷ (16 MB/s applied − 12 MB/s arriving) = 60 s
Raising apply capacity to 16 MB/s clears the backlog in another minute, assuming those rates remain constant.
Matching arrival capacity only stops further growth. Recovery requires spare capacity.
Possible responses depend on the bottleneck. Reduce browsing work on an overloaded reader. Give replay more resources. Separate heavy reporting from latency-sensitive browsing.
Adding a reader can spread query work, but each new reader must still process this group’s change stream. At much larger write volume, revisit Sharding: splitting data across databases.
Failure should not flood the primary
Consider one reader disconnecting during the sale. The surviving reader cannot accept all displaced traffic within its budget.
Blind fallback would send thousands of browsing queries to the primary. That could turn a browsing outage into failed seller saves and checkout requests.
Our scenario therefore reserves primary capacity for writes and fresh reads. Optional browsing fails temporarily when no reader qualifies. A different product might choose capped primary fallback, but that cap must fit measured spare capacity.
| Failure | User sees | Detect it | Recovery |
|---|---|---|---|
| Reader unreachable | Some browsing requests fail | Connection failures and | Remove it; reconnect and catch up |
| Reader applying slowly | Older pages, then reduced browsing availability | Growing apply backlog | Reduce query pressure or increase replay capacity |
| Primary unavailable | Saves and fresh reads fail | Write connection failures | Recover the primary or perform controlled failover |
| Monitoring stops updating | Browsing routes become ineligible | Observation age exceeds policy | Restore observations before re-admitting readers |
Promotion is not ordinary read routing. It changes which database accepts writes. Asynchronous replication can leave acknowledged commits missing after failover.1 Kartly must not promote a reader merely because one request timed out.
Observe progress, not just reachability
PostgreSQL exposes sent, received and replayed log positions. Comparing those stages helps distinguish transport delay from replay delay.1
For our routing policy, monitor each reader separately. A fleet average can hide the one reader returning old prices.
A useful operational check writes a changing marker on the primary and observes it through each reader. Its visibility delay tests the path users depend on. Treat the marker as a diagnostic, not proof that every later read meets a bound.
Re-admit a recovered reader after sustained acceptable progress, rather than after its first successful connection. That avoids repeatedly moving traffic onto a reader still catching up.
Where this shows up
- Design: a URL shortener for millions of users: a newly created link must not look missing immediately afterward.
- Design: a fast product catalog page: browsing freshness and checkout correctness need different promises.
Replicas increase the number of database endpoints, so the next problem is controlling how many connections the application opens.
Common mistakes
- Route every read to replicas. Classify the workflow’s freshness requirement, not just the SQL statement.
- Treat a timer as proof of replay. A replication stall can last longer than the timer.
- Use receipt as proof of visibility. Pending records still need to be applied.
- Fallback without a capacity budget. Protect essential primary traffic before accepting displaced browsing work.
- Assume more copies divide writes. Each reader still processes the group’s changes.
Check your understanding
A seller save succeeds, but a replica returns no product for the newly created ID. Should the app conclude creation failed?Show answer
A reader has 300 MB pending. Changes arrive at 10 MB/s and replay also runs at 10 MB/s. When does it catch up?Show answer
A synchronous replica confirms that it persisted a commit’s records. Can Kartly now read the change from any replica?Show answer
Both browsing replicas fail. Why might returning a temporary browsing error be safer than retrying every query on the primary?Show answer
Checkout reads stock from the primary, then reserves it in a separate unprotected step. Have replicas been removed as the only correctness problem?Show answer
Summary
- Move reads safely by matching each workflow’s freshness promise to its database route.
- A successful primary commit does not mean a replica has applied it.
- Keep writes, seller editing and checkout decisions on the primary in this design.
- A replication backlog shrinks only when apply capacity exceeds arriving work.
- Monitor reader progress and limit fallback before a reader failure overloads the primary.