Note: Company names, engineers, incidents, numbers, and scaling scenarios in this article are hypothetical — even when they resemble real ones. See the full disclaimer.
Follower reads and bounded staleness
It is 13:47 on a Wednesday and Asha, an SRE at YatriBook, is staring at a 50ms p99 budget for the homepage fare-cache lookup. The leader for that shard lives in Mumbai. Her users in Singapore see 78ms RTT to Mumbai before her code does anything. The dashboard says fare-cache reads alone consume 152ms p99 from Singapore, ten times what the product team promised. The naive fix — "just read from the Singapore follower" — works, except the follower is sometimes 4.2 seconds behind the leader, and a stale fare shown to a user is a customer complaint that ends in a chargeback.
What Asha actually needs is not "read from the local follower" but read from the local follower if and only if its staleness is provably under 200ms. That clause — provably under — is the entire chapter. Follower reads sound trivial; bounded staleness is what makes them safe to ship.
Follower reads serve read traffic from a non-leader replica to avoid cross-region RTT, but they expose stale data because replication is asynchronous. Bounded staleness is a contract — the system promises a follower's data is no more than X seconds (or Y log entries) behind the leader, with a fallback when that bound is breached. The engineering is the bound's mechanism: timestamp watermarks, replication-lag tracking, lease-based safe-read intervals, and the fallback path when the bound is exceeded.
What goes wrong if you "just read the follower"
Consider PaySetu's customer-balance read. The leader for shard users[0:0x10000] lives in Mumbai. A follower lives in Hyderabad (12ms RTT) and another in Chennai (18ms RTT). A user in Chennai issues a balance read; the load balancer routes to the local Chennai follower; the follower returns ₹4,820. Two seconds earlier the user transferred ₹4,820 to a friend; the leader has applied the debit and the new balance is ₹0; the Chennai follower has not yet replayed the WAL entry. The user sees ₹4,820, refreshes, sees ₹4,820 again, attempts another transfer for ₹4,820, and the leader rejects it with "insufficient balance" — confusing the user, generating a support call, and producing a fraud-investigation flag because the same user appeared to attempt the same transfer twice.
The honest framing: a follower is a leader from N milliseconds ago, where N is governed by the replication mechanism (WAL streaming bandwidth, log-shipping delay, network RTT) and the leader's recent write rate. Under steady-state low load, N might be 8ms; during a write storm — say PaySetu's salary credit batch at month-end — N can balloon to 4 seconds. Why: replication lag is bursty rather than smooth because the leader's WAL fsync queue and the follower's apply thread are both single-threaded per shard; under a write spike, queue depth grows linearly while drain rate stays constant, so the lag accumulates as the integral of the burst until the spike subsides. A read served by the follower without any awareness of N is implicitly betting the user does not care about the gap. For balance reads, fare quotes, inventory counts, and authentication tokens, that bet loses regularly.
The fix is not "never read from followers" — paying 78ms RTT for every read across regions is a non-starter for any latency-sensitive product. The fix is to expose N as a first-class quantity: measure it, bound it, attach it to read responses, and define a fallback when the bound is exceeded.
The bounded-staleness contract
A bounded-staleness read is a read with an attached SLA: the data returned is no more than B time-units (or K log entries) behind the most recent committed write. The contract has three parts:
-
The bound itself. "B = 200ms" or "K = 1000 log entries" — a number both client and server agree on. Spanner exposes this as
read_only.max_staleness. CockroachDB exposes it asAS OF SYSTEM TIME -200ms. DynamoDB'sConsistentRead=falseis the un-bounded version (and therefore the wrong primitive for most production reads — you don't know how stale). -
The mechanism that enforces the bound. The follower must know its own staleness — typically by tracking the latest log entry it has applied and comparing to a watermark from the leader, or by computing
wall_clock_now - last_applied_entry.commit_timestamp. Without this measurement, the bound is a wish. -
The fallback when the bound is breached. The follower's lag exceeds 200ms. What now? Three legitimate answers: route to the leader (paying RTT), return an error the client retries with stronger consistency, or serve the stale data with a header that tells the caller. Each has different ergonomics — fintech reads cannot serve breached-bound data; cricket-score widgets can.
The third part — fallback — is where most implementations are weak. A common bug pattern is: the follower returns "OK, here's the data, by the way I'm 4 seconds behind" via a header, but the application never reads the header. The bound exists in the database's mind and not in the application's. Bounded staleness is therefore a protocol-level contract — the follower must refuse to serve when the bound is breached unless the client explicitly opts into the breach.
There are two common bound encodings, and they are not equivalent:
- Time-bounded staleness: "follower data ≤ B seconds behind leader". Easy to reason about, hard to measure precisely (requires synchronised clocks, or clock-domain trickery like Spanner's TrueTime). The error bar on the bound itself is the clock-skew error bar.
- Log-bounded staleness: "follower has applied at least up to log entry K". Precise, requires no clocks, but K must be communicated from the leader to the client somehow — typically returned by the most recent write the client made, then sent back as a "minimum read fence" on the follower read.
Production systems often use both: log-bounded for read-your-writes within a session (the client carries the fence from the last write), time-bounded for fresh-data SLAs across sessions. Why: log-bounded fences need no clocks but require the client to remember a token across requests, so they fit single-session RYW; time-bounded works across sessions because the bound is an absolute clock interval that any client can interpret without coordination, but it costs a synchronised clock with bounded ε. Cassandra's LOCAL_QUORUM doesn't expose either directly; CockroachDB's AS OF SYSTEM TIME is time-bounded; Spanner's read_only.exact_staleness is time-bounded with TrueTime ε baked in.
Implementing bounded-staleness reads in Python
Here is a runnable simulation of a three-replica leader/follower cluster with bounded-staleness reads. It demonstrates the watermark-tracking, the bound check, and the fallback. Save and run it:
# bounded_staleness_demo.py
# Three-replica simulation: 1 leader, 2 followers with realistic replication lag.
# Demonstrates the bounded-staleness read protocol and its fallback.
import random
import time
from collections import deque
from dataclasses import dataclass, field
@dataclass
class LogEntry:
index: int
key: str
value: str
commit_time_ms: float
@dataclass
class Replica:
name: str
is_leader: bool = False
log: list = field(default_factory=list)
rtt_to_leader_ms: float = 0.0 # one-way replication delay
def apply_replication(self, entries, now_ms):
# Simulate the entries arriving rtt_to_leader_ms in the future
for e in entries:
arrival = e.commit_time_ms + self.rtt_to_leader_ms + random.gauss(0, 2)
if arrival <= now_ms:
self.log.append(e)
def lag_ms(self, leader_now_ms):
if not self.log:
return float("inf")
return leader_now_ms - self.log[-1].commit_time_ms
def serve_read(replica, key, leader, max_staleness_ms, now_ms):
lag = replica.lag_ms(now_ms)
if lag > max_staleness_ms:
# Fallback: forward to leader (pays cross-region RTT)
latest = next((e for e in reversed(leader.log) if e.key == key), None)
return ("LEADER", latest.value if latest else None, 0.0, replica.rtt_to_leader_ms)
latest = next((e for e in reversed(replica.log) if e.key == key), None)
return ("FOLLOWER", latest.value if latest else None, lag, 0.0)
if __name__ == "__main__":
random.seed(7)
leader = Replica("Mumbai-leader", is_leader=True)
fol_chennai = Replica("Chennai-follower", rtt_to_leader_ms=18.0)
fol_singapore = Replica("Singapore-follower", rtt_to_leader_ms=78.0)
# Simulate 200 writes spaced 5ms apart
t = 0.0
pending = []
for i in range(200):
e = LogEntry(index=i, key=f"user:{i % 20}", value=f"balance={1000 + i}", commit_time_ms=t)
leader.log.append(e)
pending.append(e)
t += 5.0
fol_chennai.apply_replication(pending, now_ms=t)
fol_singapore.apply_replication(pending, now_ms=t)
for follower in (fol_chennai, fol_singapore):
served = leader_path = 0
for key in [f"user:{i}" for i in range(20)]:
src, _val, lag, fallback_rtt = serve_read(follower, key, leader, max_staleness_ms=50, now_ms=t)
if src == "FOLLOWER":
served += 1
else:
leader_path += 1
print(f"{follower.name}: lag_now={follower.lag_ms(t):6.1f}ms "
f"local_served={served:2d} forwarded_to_leader={leader_path:2d}")
Sample run:
Chennai-follower: lag_now= 20.4ms local_served=20 forwarded_to_leader= 0
Singapore-follower: lag_now= 79.7ms local_served= 0 forwarded_to_leader=20
A per-line walkthrough of the load-bearing logic:
def lag_ms(self, leader_now_ms)— the follower's lag is the wall-clock delta between now and the commit timestamp of the most recent applied entry. Cheap, correct under skew below the bound's grain.if lag > max_staleness_ms— the bound check. Breaching the bound flips the read onto the slow path automatically; the application never sees stale data labelled fresh.return ("LEADER", ..., replica.rtt_to_leader_ms)— the fallback path returns the cross-region cost so the caller can record a metric. This is critical: you cannot tell whether your bound is too tight without seeing how often the fallback fires. Why: anbounded_staleness_fallback_ratiometric is the proxy for the system's read-locality. If 30% of reads are forwarding to the leader, your bound is wrong (too tight) or your replication is broken — both worth paging on, and indistinguishable without the metric.apply_replication(entries, now_ms)— entries are applied to the follower only if they would have already arrived, simulating the network RTT and a small Gaussian jitter. Realistic enough to see the difference between Chennai (18ms) and Singapore (78ms) — the latter has lag higher than the 50ms bound, so all reads forward to the leader.
The output makes the trade-off concrete: with a 50ms bound, Chennai is a profitable follower-read target; Singapore is not (its 78ms baseline replication lag exceeds the bound, so every read pays the cross-region hop anyway). The actionable insight is that bounded-staleness reads are profitable only when the bound is generous relative to your replication-lag distribution — if the bound is tighter than the p99 of replication lag, the fast path is irrelevant.
Watermarks, leases, and read-your-writes
The mechanism that lets a follower know its own lag is the watermark. The leader publishes its commit index and a wall-clock timestamp on every heartbeat (typically every 50–200ms); each follower stores the most recent watermark and uses it to compute lag as watermark.timestamp - last_applied.timestamp + (now - watermark.received_at). The last term — wall-clock drift since the watermark arrived — is what forces the bound to include a clock-skew margin. Spanner's TrueTime returns [earliest, latest] instead of a point, and the bound is checked against latest; CockroachDB uses a configurable max_offset of 500ms by default, and any read that would breach bound - max_offset falls back to the leader.
A subtler issue is read-your-writes (RYW) within a session. A user writes ₹500 to their balance, then immediately refreshes the page; the read goes to a follower that hasn't seen the write yet; the user sees the old balance and panics. The fix is a session fence — the write returns a commit timestamp (or log index), the client carries it, and the next read includes "must be at least as fresh as fence T". The follower then compares its watermark against T: if watermark.timestamp >= T, serve locally; otherwise, wait briefly (200ms) for a heartbeat to arrive, then either serve or forward. This is what AWS calls strongly-consistent reads with a session token; what Spanner calls bounded staleness with a min_read_timestamp; and what CockroachDB calls follower reads with a closed timestamp.
The session-fence pattern composes nicely with the time-bounded pattern: time-bounded for cross-session staleness ("never older than 5 seconds"), session-fenced for within-session ("at least as fresh as my own last write"). Most production reads need both, and exposing one without the other produces the bug class of "data looks correct in average load testing, looks wrong on the user's screen the moment they do anything".
A real production failure that bit MealRush's wallet team: the wallet write returned 200 OK, the customer's app refreshed, the new balance appeared briefly, then the screen flickered to the old balance, then back. The cause: the write went to the leader, the immediate refresh hit the leader (correct), and a third refresh — fired by the app's auto-poller 800ms later — hit a follower whose lag was 1.4s. The follower returned the pre-write balance, which the UI rendered, replacing the correct value. The fix was a session token in the user's cookie; subsequent follower reads carried the token, and the follower waited for its watermark to catch up before serving. The bug had been live for nine months. Without a session-fence mechanism, RYW within a region is best-effort.
Common confusions
- "Bounded staleness is the same as eventual consistency." No. Eventual consistency promises convergence in the limit with no time bound; bounded staleness gives a concrete number and a fallback when the number is breached. Eventual consistency is a system property; bounded staleness is a per-read SLA.
- "Follower reads are always faster than leader reads." No — only when the bound is wide enough that the fast path actually fires. If your bound is 5ms and your replication lag p99 is 80ms, every read will fall back to the leader and you've added a hop for nothing.
- "max_staleness=0 gives me linearisability for free." No.
max_staleness=0typically means "force read at the leader" or, with TrueTime, "wait out clock uncertainty before serving". Linearisability requires both a fresh-enough timestamp and a leader-lease check; getting it via follower-read APIs is usually a misconfiguration of the stronger primitive. - "Bounded staleness lets me ignore replication lag." Inverted — bounded staleness forces you to treat replication lag as a first-class observable. Without measuring it, the bound is a hope.
- "The bound applies to one read; my transaction has many." A multi-statement transaction with bounded-staleness reads can observe an inconsistent snapshot across statements (statement 1 reads at T-50ms, statement 2 reads at T-180ms because lag changed). Use a single read timestamp for the entire transaction (Spanner's
read_timestamp, CockroachDB'sAS OF SYSTEM TIME) to get a consistent snapshot. - "Closed timestamps and bounded staleness are the same thing." Closed timestamps are a mechanism — the leader periodically declares a timestamp T such that no future write will commit with timestamp ≤ T. Bounded staleness is a contract — reads up to T are safe. You implement bounded staleness using closed timestamps (CockroachDB does); you can implement bounded staleness without closed timestamps (Cassandra does, less precisely); but they are distinct concepts.
Going deeper
Closed timestamps in CockroachDB
CockroachDB's follower-read implementation centres on the closed timestamp — a per-range marker, advanced every 3 seconds by default, declaring "all writes with timestamp ≤ T have either committed or aborted". A follower with a closed timestamp T can serve any read at timestamp ≤ T without consulting the leader, because the leader has promised not to commit anything earlier. The user-facing primitive is AS OF SYSTEM TIME -3s (or follower_read_timestamp()), which guarantees follower-read eligibility on every range. The trade-off is the 3-second lag — a default tuned for "reads that don't need to be fresher than a few seconds, served at sub-10ms regardless of region". Tighter bounds are configurable but require more frequent closed-timestamp updates, which costs leader-side bookkeeping.
Spanner's bounded staleness with TrueTime
Spanner's read_only.max_staleness parameter takes a duration; the read is served at TT.now().latest - max_staleness. The TrueTime bound — typically ε = 7ms — is part of the equation: a 200ms max_staleness actually reads at "between 193ms and 200ms ago", and the system picks the freshest server able to serve at that point. Critically, Spanner blocks the read briefly if the chosen server hasn't caught up to the chosen read timestamp; the application sees a tiny extra latency rather than stale data. The TrueTime hardware (GPS receivers + atomic clocks per datacenter) is what makes this engineering tractable; without it, the ε is too wide to bound staleness usefully.
When bounded staleness is the wrong primitive
For data where staleness has zero acceptable bound — payment authorisation, stock-order limits, OTP single-use — bounded staleness is wrong. Use leader-only reads with read leases, or linearisable reads via Raft's read index. Bounded-staleness reads are a tool for the bulk of read traffic — feeds, profiles, catalogue, search, dashboards — where 200ms of staleness is invisible. Conflating "most reads can tolerate this" with "all reads can tolerate this" produces the class of bugs where 99.5% of users never notice and 0.5% lose money. KapitalKite, a hypothetical stockbroker, ran their portfolio dashboard on bounded-staleness reads and their order-placement screen on leader reads; mixing the two in one screen caused a margin-call notice to be shown next to a 4-seconds-stale balance, and a customer placed an order against the stale value. The fix was rule-of-thumb: any UI element that affects a user's next monetary action reads at the leader; everything else reads bounded.
Replication-lag observability is the prerequisite
Before you ship bounded-staleness reads, you need a replication_lag_seconds{follower=...} Prometheus histogram with quantiles at p50, p99, p99.9, refreshed every 10 seconds. Without it, you cannot pick a bound, cannot detect when the bound is too tight, cannot alert on a follower falling behind, and cannot post-mortem an incident. Every implementation that ships before the observability ships is a system whose owner will discover a lag pathology at 02:00 IST during a live incident. The order is: measure first, bound second, route reads third.
Reproduce this on your laptop
python3 -m venv .venv && source .venv/bin/activate
pip install simpy
python3 bounded_staleness_demo.py
# Try varying max_staleness_ms (10, 50, 100, 200) and watch the
# Singapore-follower forwarding ratio change.
Where this leads next
Bounded staleness is the entry point to the rest of the geo-distribution toolkit. Once you have a working follower-read path, the next questions are: where do leaders live (leader placement and home regions), how do you partition data so each user's home region holds their leader (geo-partitioned data), and how do you handle the long tail when a region disconnects entirely (regional failure and follower promotion).
The thread to hold: bounded-staleness reads convert the cross-region latency floor (which you cannot eliminate) into a configurable point on a latency-vs-freshness Pareto curve (which you can negotiate per workload). That negotiation is the entire subject of the next several chapters.
References
- Corbett, J. et al. (2012). Spanner: Google's Globally-Distributed Database. OSDI '12. The TrueTime + bounded staleness model.
- Taft, R. et al. (2020). CockroachDB: The Resilient Geo-Distributed SQL Database. SIGMOD '20. Closed timestamps and follower reads.
- Bailis, P., Ghodsi, A. (2013). Eventual Consistency Today: Limitations, Extensions, and Beyond. ACM Queue. Defines bounded staleness as a consistency point.
- Terry, D. (2013). Replicated Data Consistency Explained Through Baseball. CACM. The canonical taxonomy that includes bounded-staleness as one of six consistency models.
- Abadi, D. (2012). Consistency Tradeoffs in Modern Distributed Database System Design. IEEE Computer. Why the L-vs-C tradeoff in PACELC's else-arm is what bounded staleness navigates.
- Microsoft. Azure Cosmos DB consistency levels (docs). Bounded staleness as a first-class API choice.
- Internal: wall: one datacenter isn't enough, phi-accrual failure detection, hybrid logical clocks.