System Design (High-Level Design) Module 8 – Replication
Replication lag and read-your-writes guarantees
Why reads from lagging followers show users old data, the three anomalies it causes, and how sessions get read-your-writes and monotonic reads.
What you will learn
- Recognise the three anomalies that replica lag causes, from what a user sees
- Compare four read-routing rules by the session guarantees each gives
- Implement a position-token router that gives read-your-writes, monotonic reads and consistent prefix reads
- Measure lag in time and in log position, and size a read-from-leader window from it
Before you start
What you’ll learn · Before you start
What you will learn
- Recognise the three anomalies that replica lag causes, from what a user sees
- Compare four read-routing rules by the session guarantees each gives
- Implement a position-token router that gives read-your-writes, monotonic reads and consistent prefix reads
- Measure lag in time and in log position, and size a read-from-leader window from it
Before you start
On this page
Asha renames herself, opens her profile a second later and sees her old name: the read landed on a follower three seconds behind the leader. replication lag: How far a follower is behind its leader: the log positions it has not applied yet, or the time before a commit on the leader becomes visible on the follower. More in “Leader-follower replication and replication logs” is the time between a write committing on the leader and the same write becoming visible on a follower. It is usually milliseconds, and it grows under heavy load, behind long-running queries and after network trouble. Every read sent to a follower may return data from before the lag, and users notice in three ways: their own change is missing, a refresh shows older data than the page before, or an answer appears without its question.
read-your-writes: A session guarantee: after a session writes, its later reads show that write, even when they are served by followers that lag behind the leader. guarantees and two companions, monotonic reads: A session guarantee: once a session has seen some state of the data, its later reads never show an older one, so a refresh never goes back in time. and consistent prefix reads: A session guarantee: a session sees writes in the order they were made, so it never sees an answer without the question it answers., rule these out without sending every read to the leader. One routing rule, a position token, gives all three, and it fits in a few lines of code.
Three anomalies come from one cause: reads at different positions
A follower shows the leader’s data as it was a moment ago. That alone is harmless. The trouble starts when one person’s reads land on followers at different positions, or on a follower that has not reached that person’s own write.
One lagging follower, three anomalies
Text description of the diagram
At the top is the leader, whose log holds positions 1 to 8. Position 6 is a question someone posted, position 7 is its answer, and position 8 is Asha's change of name.
The leader streams its log to two followers. Follower A has applied up to position 7, one behind the leader. Follower B gets the log three seconds late and has applied only up to position 5.
Both followers serve reads, and the box at the bottom lists three readers whose reads land on both: 1. Asha, who made the change at position 8, reads her profile from follower B and sees her old name: her own write is missing. 2. Ravi reads the thread from follower A, where the answer is present, then refreshes and lands on follower B, where it is not: what he sees goes back in time. 3. A page loads the answer (position 7) from follower A and the question (position 6) from follower B, so it shows an answer to a question that is not there.
Step through the diagram’s three readers, one read at a time:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- One log, two positions. The leader has written positions 1 to 8. Position 6 is a question, 7 its answer and 8 Asha's new name. Follower A has applied up to 7, follower B, three seconds behind, up to 5.
- Asha reads from B. Asha renamed herself at position 8, then opens her profile. The read lands on B, which stopped at 5, and her old name is back. Her own write is missing.
- Ravi reads from A. Ravi opens the thread. His read lands on A, which has applied position 7, and he sees the answer.
- Ravi refreshes on B. His refresh lands on B, at 5, and the answer he has just read is gone. What he sees went back in time.
- A page reads both. A page loads the answer, position 7, from A and the question it answers, position 6, from B. B has not reached 6, so the page shows an answer to a question that is not there.
- One cause. Each follower shows a correct earlier state. The trouble is that one session's reads landed at different positions, or at a position before the session's own write.
Each anomaly as a user sees it, and the guarantee that rules it out:
| Anomaly | What the user sees | The guarantee that rules it out |
|---|---|---|
| A missing write | “I saved it, and it is gone” | Read-your-writes: a session sees its own writes |
| Time running backwards | “The comment vanished when I refreshed” | Monotonic reads: later reads never show an older state |
| Effect before cause | “An answer to a question that is not there” | Consistent prefix reads: writes appear in the order they were made |
The names come from research on replicated data; Doug Terry’s report that explains them through the score of a baseball game is the friendliest introduction, and calls the first one “read my writes” (Terry). All three concern the reads of one session, not the whole system, which is why they are called session guarantee: A promise about the reads of one session against lagging replicas, such as read-your-writes, rather than about every client at once. It costs far less than keeping every replica current. and why they cost far less than keeping every follower current. Read-your-writes is sticky available: it survives a network partition as long as each client keeps talking to the same server (Jepsen).
Checkpoint
Check: The three anomalies and their cause
Four routing rules, and the guarantees each gives
Replication lag cannot be removed, so every fix decides where a read goes. Four are in common use:
- Read from the leader after a write. For a short window after a session writes, its reads go to the leader, which always has the write.
- Sticky follower. Each session reads from one follower, so its reads never go back in time.
- Position token. The session remembers the log position of its latest write or read, and reads only from followers that have applied it.
- Lag-aware routing. Followers more than a set limit behind are taken out of rotation for everyone.
Only the position token gives all three guarantees by construction. It needs a little bookkeeping: the leader tells the client each commit’s position, and followers report how far they have applied. Read-your-writes guarantees from a time window are only as good as the window, because a follower further behind than the window is long still lacks the write.
| Criterion | Leader after a write | Sticky follower | Position token | Lag-aware routing |
|---|---|---|---|---|
| Read-your-writes | Inside the window only | No, if the follower lags behind the write | Yes | No |
| Monotonic reads | No: reads jump from the leader to a follower | Yes | Yes | No |
| Consistent prefix | No | Yes, for reads on one follower | Yes | No |
| Main cost | Leader reads after every write | Uneven load; the session moves when its follower fails | Positions to carry; an occasional wait | Fewer followers during trouble |
| When to choose | A quick first step for profile edits | Feeds and lists that must not jump back | Anything a user just changed | Every design, as a safety net beside the others |
Key idea
Every fix decides where a read goes; only a position token, which reads from followers that have applied the session’s latest write or read, gives all three guarantees at once.
Checkpoint
Check: Four read-routing rules compared
A position token gives all three guarantees, in a few lines
Each write returns its commit position, a session keeps the highest position it has written or read, and a read goes to a follower that has applied the session’s token, or to the leader when none has. The program runs the diagram’s three readers twice, through a plain balancer that picks the follower in the diagram and through the token router:
Python 3.14 · main.py
LOG = [ # positions 2 to 5 change other keys
(1, "name", "Asha"),
(6, "question", "Which mode?"),
(7, "answer", "Use ANY 1"),
(8, "name", "Asha Rao"),
]
APPLIED = {"A": 7, "B": 5}
CASES = [ # token, keys read, the followers a plain balancer picks
("Asha reads her name after renaming at 8", 8, ["name"], "B"),
("Ravi reads the answer, then refreshes", 0, ["answer", "answer"], "AB"),
("A page shows an answer and its question", 0, ["answer", "question"], "AB"),
]
def seen(key: str, server: str, position: int) -> str:
values = [value for at, k, value in LOG if at <= position and k == key]
return f"{key} {values[-1] if values else 'nothing'} from {server}"
def route(token: int) -> tuple[str, int]:
# The first follower at or past the token serves the read, else the leader.
leader = ("leader", LOG[-1][0])
return next(((f, at) for f, at in APPLIED.items() if at >= token), leader)
for title, token, keys, picks in CASES:
naive, routed = [], []
for key, pick in zip(keys, picks):
naive.append(seen(key, pick, APPLIED[pick]))
server, at = route(token)
routed.append(seen(key, server, at))
token = max(token, at) # the token rises to the position read
print(title)
print(f" naive: {', '.join(naive)}")
print(f" token: {', '.join(routed)} (token {token})") In Python: next() with a default returns the leader when no follower has reached the token, and zip pairs each key with the follower a plain balancer picks.
Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
const LOG = [
// positions 2 to 5 change other keys
[1, "name", "Asha"],
[6, "question", "Which mode?"],
[7, "answer", "Use ANY 1"],
[8, "name", "Asha Rao"],
];
const APPLIED = { A: 7, B: 5 };
const CASES = [
// token, keys read, the followers a plain balancer picks
["Asha reads her name after renaming at 8", 8, ["name"], "B"],
["Ravi reads the answer, then refreshes", 0, ["answer", "answer"], "AB"],
["A page shows an answer and its question", 0, ["answer", "question"], "AB"],
];
function seen(key, server, position) {
const values = LOG.filter(([at, k]) => at <= position && k === key);
return `${key} ${values.length ? values[values.length - 1][2] : "nothing"} from ${server}`;
}
// The first follower at or past the token serves the read, else the leader.
const route = (token) =>
Object.entries(APPLIED).find(([, at]) => at >= token) ?? ["leader", LOG[LOG.length - 1][0]];
for (let [title, token, keys, picks] of CASES) {
const naive = [];
const routed = [];
keys.forEach((key, i) => {
naive.push(seen(key, picks[i], APPLIED[picks[i]]));
const [server, at] = route(token);
routed.push(seen(key, server, at));
token = Math.max(token, at); // the token rises to the position read
});
console.log(title);
console.log(` naive: ${naive.join(", ")}`);
console.log(` token: ${routed.join(", ")} (token ${token})`);
} In JavaScript: find returns undefined when no follower qualifies, so ?? supplies the leader; Object.entries keeps the followers in the order they were written.
Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class Main {
record Write(int position, String key, String value) {}
record Case(String title, int token, String picks, String... keys) {}
// positions 2 to 5 change other keys
static final List<Write> LOG =
List.of(
new Write(1, "name", "Asha"),
new Write(6, "question", "Which mode?"),
new Write(7, "answer", "Use ANY 1"),
new Write(8, "name", "Asha Rao"));
static final List<String> FOLLOWERS = List.of("A", "B");
static final Map<String, Integer> APPLIED = Map.of("A", 7, "B", 5);
static String seen(String key, String server, int position) {
String value = "nothing";
for (Write write : LOG) {
if (write.position() <= position && write.key().equals(key)) {
value = write.value();
}
}
return key + " " + value + " from " + server;
}
// The first follower at or past the token serves the read, else the leader.
static Map.Entry<String, Integer> route(int token) {
for (String follower : FOLLOWERS) {
if (APPLIED.get(follower) >= token) {
return Map.entry(follower, APPLIED.get(follower));
}
}
return Map.entry("leader", LOG.get(LOG.size() - 1).position());
}
public static void main(String[] args) {
// token, the followers a plain balancer picks, keys read
List<Case> cases =
List.of(
new Case("Asha reads her name after renaming at 8", 8, "B", "name"),
new Case("Ravi reads the answer, then refreshes", 0, "AB", "answer", "answer"),
new Case("A page shows an answer and its question", 0, "AB", "answer", "question"));
for (Case c : cases) {
int token = c.token();
List<String> naive = new ArrayList<>();
List<String> routed = new ArrayList<>();
for (int i = 0; i < c.keys().length; i++) {
String key = c.keys()[i];
String pick = String.valueOf(c.picks().charAt(i));
naive.add(seen(key, pick, APPLIED.get(pick)));
var read = route(token);
routed.add(seen(key, read.getKey(), read.getValue()));
token = Math.max(token, read.getValue()); // the token rises to the position read
}
System.out.println(c.title());
System.out.println(" naive: " + String.join(", ", naive));
System.out.println(" token: " + String.join(", ", routed) + " (token " + token + ")");
}
}
} In Java: Case takes its keys as a varargs component, and Map.of keeps no order, so FOLLOWERS fixes the order in which route tries them.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <algorithm>
#include <map>
#include <print>
#include <string>
#include <utility>
#include <vector>
struct Write {
int position;
std::string key, value;
};
struct Case {
std::string title;
int token;
std::vector<std::string> keys;
std::string picks;
};
// positions 2 to 5 change other keys
const std::vector<Write> log_ = {{1, "name", "Asha"},
{6, "question", "Which mode?"},
{7, "answer", "Use ANY 1"},
{8, "name", "Asha Rao"}};
const std::map<std::string, int> applied = {{"A", 7}, {"B", 5}};
std::string seen(const std::string &key, const std::string &server,
int position) {
std::string value = "nothing";
for (const Write &w : log_) {
if (w.position <= position && w.key == key) {
value = w.value;
}
}
return key + " " + value + " from " + server;
}
// The first follower at or past the token serves the read, else the leader.
std::pair<std::string, int> route(int token) {
for (const auto &[follower, at] : applied) {
if (at >= token) {
return {follower, at};
}
}
return {"leader", log_.back().position};
}
int main() {
// token, keys read, the followers a plain balancer picks
const std::vector<Case> cases = {
{"Asha reads her name after renaming at 8", 8, {"name"}, "B"},
{"Ravi reads the answer, then refreshes", 0, {"answer", "answer"}, "AB"},
{"A page shows an answer and its question",
0,
{"answer", "question"},
"AB"}};
for (const Case &c : cases) {
int token = c.token;
std::string naive, routed;
for (std::size_t i = 0; i < c.keys.size(); i++) {
const std::string pick(1, c.picks[i]), sep = i ? ", " : "";
naive += sep + seen(c.keys[i], pick, applied.at(pick));
const auto [server, at] = route(token);
routed += sep + seen(c.keys[i], server, at);
token = std::max(token, at); // the token rises to the position read
}
std::println("{}\n naive: {}\n token: {} (token {})", c.title, naive,
routed, token);
}
} In C++: std::map keeps its keys sorted, so route tries A before B, and applied.at(pick) would throw if a pick named no follower.
Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
// positions 2 to 5 change other keys
(int Position, string Key, string Value)[] log =
[
(1, "name", "Asha"),
(6, "question", "Which mode?"),
(7, "answer", "Use ANY 1"),
(8, "name", "Asha Rao"),
];
(string Name, int Applied)[] followers = [("A", 7), ("B", 5)];
// token, keys read, the followers a plain balancer picks
(string Title, int Token, string[] Keys, string Picks)[] cases =
[
("Asha reads her name after renaming at 8", 8, ["name"], "B"),
("Ravi reads the answer, then refreshes", 0, ["answer", "answer"], "AB"),
("A page shows an answer and its question", 0, ["answer", "question"], "AB"),
];
foreach (var (title, first, keys, picks) in cases)
{
int token = first;
var naive = new List<string>();
var routed = new List<string>();
for (int i = 0; i < keys.Length; i++)
{
var pick = followers.First(f => f.Name == picks[i].ToString());
naive.Add(Seen(keys[i], pick.Name, pick.Applied));
var (server, at) = Route(token);
routed.Add(Seen(keys[i], server, at));
token = Math.Max(token, at); // the token rises to the position read
}
Console.WriteLine(title);
Console.WriteLine($" naive: {string.Join(", ", naive)}");
Console.WriteLine($" token: {string.Join(", ", routed)} (token {token})");
}
string Seen(string key, string server, int position)
{
string? value = log.LastOrDefault(w => w.Position <= position && w.Key == key).Value;
return $"{key} {value ?? "nothing"} from {server}";
}
// The first follower at or past the token serves the read, else the leader.
(string Name, int Applied) Route(int token) =>
followers.FirstOrDefault(f => f.Applied >= token, (Name: "leader", Applied: log[^1].Position)); In C#: FirstOrDefault takes the leader as its default, and LastOrDefault returns a default tuple, whose Value is null, when no write matches.
Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
#include <stdio.h>
#include <string.h>
typedef struct {
int position;
const char *key, *value;
} Write;
// positions 2 to 5 change other keys
static const Write log_[] = {{1, "name", "Asha"},
{6, "question", "Which mode?"},
{7, "answer", "Use ANY 1"},
{8, "name", "Asha Rao"}};
static const struct {
const char *name;
int applied;
} followers[] = {{"A", 7}, {"B", 5}};
enum { LOG_SIZE = sizeof log_ / sizeof log_[0], FOLLOWERS = 2 };
static void seen(const char *key, const char *server, int position) {
const char *value = "nothing";
for (size_t i = 0; i < LOG_SIZE; i++) {
if (log_[i].position <= position && strcmp(log_[i].key, key) == 0) {
value = log_[i].value;
}
}
printf("%s %s from %s", key, value, server);
}
// The first follower at or past the token serves the read, else the leader
// (-1).
static int route(int token) {
for (int f = 0; f < FOLLOWERS; f++) {
if (followers[f].applied >= token) {
return f;
}
}
return -1;
}
int main(void) {
// token, keys read, the followers a plain balancer picks
static const struct {
const char *title;
int token;
const char *keys[2];
const char *picks;
} cases[] = {
{"Asha reads her name after renaming at 8", 8, {"name"}, "B"},
{"Ravi reads the answer, then refreshes", 0, {"answer", "answer"}, "AB"},
{"A page shows an answer and its question",
0,
{"answer", "question"},
"AB"}};
for (size_t c = 0; c < sizeof cases / sizeof cases[0]; c++) {
const size_t reads = strlen(cases[c].picks);
printf("%s\n naive: ", cases[c].title);
for (size_t i = 0; i < reads; i++) {
const int f = cases[c].picks[i] - 'A';
printf("%s", i ? ", " : "");
seen(cases[c].keys[i], followers[f].name, followers[f].applied);
}
int token = cases[c].token;
printf("\n token: ");
for (size_t i = 0; i < reads; i++) {
const int f = route(token);
const int at = f < 0 ? log_[LOG_SIZE - 1].position : followers[f].applied;
printf("%s", i ? ", " : "");
seen(cases[c].keys[i], f < 0 ? "leader" : followers[f].name, at);
token = at > token ? at : token; // the token rises to the position read
}
printf(" (token %d)\n", token);
}
} In C: C has no map in its standard library, so a pick's letter minus 'A' indexes the followers, and route returns -1 for the leader.
Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main
PHP 8.5 · main.php
<?php
declare(strict_types=1);
// positions 2 to 5 change other keys
const LOG = [
[1, 'name', 'Asha'],
[6, 'question', 'Which mode?'],
[7, 'answer', 'Use ANY 1'],
[8, 'name', 'Asha Rao'],
];
const APPLIED = ['A' => 7, 'B' => 5];
// token, keys read, the followers a plain balancer picks
const CASES = [
['Asha reads her name after renaming at 8', 8, ['name'], 'B'],
['Ravi reads the answer, then refreshes', 0, ['answer', 'answer'], 'AB'],
['A page shows an answer and its question', 0, ['answer', 'question'], 'AB'],
];
function seen(string $key, string $server, int $position): string
{
$value = 'nothing';
foreach (LOG as [$at, $k, $v]) {
if ($at <= $position && $k === $key) {
$value = $v;
}
}
return "{$key} {$value} from {$server}";
}
/**
* The first follower at or past the token serves the read, else the leader.
*
* @return array{string, int}
*/
function route(int $token): array
{
foreach (APPLIED as $follower => $at) {
if ($at >= $token) {
return [$follower, $at];
}
}
return ['leader', LOG[count(LOG) - 1][0]];
}
foreach (CASES as [$title, $token, $keys, $picks]) {
$naive = $routed = [];
foreach ($keys as $i => $key) {
$naive[] = seen($key, $picks[$i], APPLIED[$picks[$i]]);
[$server, $at] = route($token);
$routed[] = seen($key, $server, $at);
$token = max($token, $at); // the token rises to the position read
}
echo "{$title}\n naive: " . implode(', ', $naive) . "\n";
echo ' token: ' . implode(', ', $routed) . " (token {$token})\n";
} In PHP: Arrays keep their insertion order, so foreach tries A before B, and the string offset $picks[$i] gives one letter.
Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
// positions 2 to 5 change other keys
const LOG: [(u32, &str, &str); 4] = [
(1, "name", "Asha"),
(6, "question", "Which mode?"),
(7, "answer", "Use ANY 1"),
(8, "name", "Asha Rao"),
];
const APPLIED: [(&str, u32); 2] = [("A", 7), ("B", 5)];
// token, keys read, the followers a plain balancer picks
const CASES: [(&str, u32, &[&str], &str); 3] = [
("Asha reads her name after renaming at 8", 8, &["name"], "B"),
(
"Ravi reads the answer, then refreshes",
0,
&["answer", "answer"],
"AB",
),
(
"A page shows an answer and its question",
0,
&["answer", "question"],
"AB",
),
];
fn seen(key: &str, server: &str, position: u32) -> String {
let found = LOG
.iter()
.rev()
.find(|&&(at, k, _)| at <= position && k == key);
let value = found.map_or("nothing", |&(_, _, value)| value);
format!("{key} {value} from {server}")
}
/// The first follower at or past the token serves the read, else the leader.
fn route(token: u32) -> (&'static str, u32) {
let leader = ("leader", LOG[LOG.len() - 1].0);
APPLIED
.into_iter()
.find(|&(_, at)| at >= token)
.unwrap_or(leader)
}
fn main() {
for (title, mut token, keys, picks) in CASES {
let (mut naive, mut routed) = (Vec::new(), Vec::new());
for (key, pick) in keys.iter().zip(picks.bytes()) {
let (name, at) = APPLIED[usize::from(pick - b'A')];
naive.push(seen(key, name, at));
let (server, at) = route(token);
routed.push(seen(key, server, at));
token = token.max(at); // the token rises to the position read
}
println!("{title}\n naive: {}", naive.join(", "));
println!(" token: {} (token {token})", routed.join(", "));
}
} In Rust: pick - b'A' turns a byte of picks into an index, and unwrap_or supplies the leader when find returns None.
Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
const LOG: [number, string, string][] = [
// positions 2 to 5 change other keys
[1, "name", "Asha"],
[6, "question", "Which mode?"],
[7, "answer", "Use ANY 1"],
[8, "name", "Asha Rao"],
];
const APPLIED: Record<string, number> = { A: 7, B: 5 };
const CASES: [string, number, string[], string][] = [
// token, keys read, the followers a plain balancer picks
["Asha reads her name after renaming at 8", 8, ["name"], "B"],
["Ravi reads the answer, then refreshes", 0, ["answer", "answer"], "AB"],
["A page shows an answer and its question", 0, ["answer", "question"], "AB"],
];
function seen(key: string, server: string, position: number): string {
const values = LOG.filter(([at, k]) => at <= position && k === key);
return `${key} ${values.length ? values[values.length - 1][2] : "nothing"} from ${server}`;
}
// The first follower at or past the token serves the read, else the leader.
const route = (token: number): [string, number] =>
Object.entries(APPLIED).find(([, at]) => at >= token) ?? ["leader", LOG[LOG.length - 1][0]];
for (let [title, token, keys, picks] of CASES) {
const naive: string[] = [];
const routed: string[] = [];
keys.forEach((key, i) => {
naive.push(seen(key, picks[i], APPLIED[picks[i]]));
const [server, at] = route(token);
routed.push(seen(key, server, at));
token = Math.max(token, at); // the token rises to the position read
});
console.log(title);
console.log(` naive: ${naive.join(", ")}`);
console.log(` token: ${routed.join(", ")} (token ${token})`);
} In TypeScript: The tuple types of LOG and CASES keep each destructured field's type, and the leader is the fallback of ??.
Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import (
"fmt"
"strings"
)
type write struct {
position int
key, value string
}
// positions 2 to 5 change other keys
var log = []write{
{1, "name", "Asha"},
{6, "question", "Which mode?"},
{7, "answer", "Use ANY 1"},
{8, "name", "Asha Rao"},
}
var followers = []string{"A", "B"}
var applied = map[string]int{"A": 7, "B": 5}
func seen(key, server string, position int) string {
value := "nothing"
for _, w := range log {
if w.position <= position && w.key == key {
value = w.value
}
}
return key + " " + value + " from " + server
}
// route: the first follower at or past the token serves the read, else the leader.
func route(token int) (string, int) {
for _, f := range followers {
if applied[f] >= token {
return f, applied[f]
}
}
return "leader", log[len(log)-1].position
}
func main() {
// token, keys read, the followers a plain balancer picks
cases := []struct {
title string
token int
keys []string
picks string
}{
{"Asha reads her name after renaming at 8", 8, []string{"name"}, "B"},
{"Ravi reads the answer, then refreshes", 0, []string{"answer", "answer"}, "AB"},
{"A page shows an answer and its question", 0, []string{"answer", "question"}, "AB"},
}
for _, c := range cases {
token := c.token
var naive, routed []string
for i, key := range c.keys {
pick := c.picks[i : i+1]
naive = append(naive, seen(key, pick, applied[pick]))
server, at := route(token)
routed = append(routed, seen(key, server, at))
token = max(token, at) // the token rises to the position read
}
fmt.Printf("%s\n naive: %s\n", c.title, strings.Join(naive, ", "))
fmt.Printf(" token: %s (token %d)\n", strings.Join(routed, ", "), token)
}
} In Go: Go randomises map order, so the followers slice fixes the order in which route tries them; c.picks[i : i+1] is a one-letter string.
Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
// positions 2 to 5 change other keys
let log: [(position: Int, key: String, value: String)] = [
(1, "name", "Asha"), (6, "question", "Which mode?"), (7, "answer", "Use ANY 1"),
(8, "name", "Asha Rao"),
]
let applied = [(name: "A", position: 7), (name: "B", position: 5)]
// token, keys read, the followers a plain balancer picks
let cases = [
("Asha reads her name after renaming at 8", 8, ["name"], "B"),
("Ravi reads the answer, then refreshes", 0, ["answer", "answer"], "AB"),
("A page shows an answer and its question", 0, ["answer", "question"], "AB"),
]
func seen(_ key: String, _ server: String, _ position: Int) -> String {
let value = log.last { $0.position <= position && $0.key == key }?.value ?? "nothing"
return "\(key) \(value) from \(server)"
}
/// The first follower at or past the token serves the read, else the leader.
func route(_ token: Int) -> (name: String, position: Int) {
applied.first { $0.position >= token } ?? ("leader", log[log.count - 1].position)
}
for (title, first, keys, picks) in cases {
var token = first
var naive: [String] = []
var routed: [String] = []
for (key, pick) in zip(keys, picks) {
let follower = applied.first { $0.name == String(pick) } ?? ("?", 0)
naive.append(seen(key, follower.name, follower.position))
let read = route(token)
routed.append(seen(key, read.name, read.position))
token = max(token, read.position) // the token rises to the position read
}
print("\(title)\n naive: \(naive.joined(separator: ", "))")
print(" token: \(routed.joined(separator: ", ")) (token \(token))")
} In Swift: last(where:) and first(where:) return optionals, and ?? supplies the default, nothing for a missing key and the leader for a route.
Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
# positions 2 to 5 change other keys
LOG = [
[1, 'name', 'Asha'], [6, 'question', 'Which mode?'],
[7, 'answer', 'Use ANY 1'], [8, 'name', 'Asha Rao']
].freeze
APPLIED = { 'A' => 7, 'B' => 5 }.freeze
# token, keys read, the followers a plain balancer picks
CASES = [
['Asha reads her name after renaming at 8', 8, %w[name], 'B'],
['Ravi reads the answer, then refreshes', 0, %w[answer answer], 'AB'],
['A page shows an answer and its question', 0, %w[answer question], 'AB']
].freeze
def seen(key, server, position)
found = LOG.select { |at, name, _| at <= position && name == key }.last
"#{key} #{found ? found[2] : 'nothing'} from #{server}"
end
# The first follower at or past the token serves the read, else the leader.
def route(token)
APPLIED.find { |_, at| at >= token } || ['leader', LOG.last.first]
end
CASES.each do |title, token, keys, picks|
naive = []
routed = []
keys.zip(picks.chars).each do |key, pick|
naive << seen(key, pick, APPLIED[pick])
server, at = route(token)
routed << seen(key, server, at)
token = [token, at].max # the token rises to the position read
end
puts title, " naive: #{naive.join(', ')}", " token: #{routed.join(', ')} (token #{token})"
end In Ruby: Hash#find yields the pairs in insertion order, and || supplies the leader when it returns nil.
Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
// positions 2 to 5 change other keys
val log =
listOf(
Triple(1, "name", "Asha"),
Triple(6, "question", "Which mode?"),
Triple(7, "answer", "Use ANY 1"),
Triple(8, "name", "Asha Rao"),
)
val applied = linkedMapOf("A" to 7, "B" to 5)
data class Case(
val title: String,
val token: Int,
val keys: List<String>,
val picks: String,
)
fun seen(
key: String,
server: String,
position: Int,
): String {
val value = log.lastOrNull { it.first <= position && it.second == key }?.third ?: "nothing"
return "$key $value from $server"
}
/** The first follower at or past the token serves the read, else the leader. */
fun route(token: Int): Pair<String, Int> {
val follower = applied.entries.firstOrNull { it.value >= token }
return follower?.toPair() ?: ("leader" to log.last().first)
}
fun main() {
// token, keys read, the followers a plain balancer picks
val cases =
listOf(
Case("Asha reads her name after renaming at 8", 8, listOf("name"), "B"),
Case("Ravi reads the answer, then refreshes", 0, listOf("answer", "answer"), "AB"),
Case("A page shows an answer and its question", 0, listOf("answer", "question"), "AB"),
)
for (c in cases) {
var token = c.token
val naive = mutableListOf<String>()
val routed = mutableListOf<String>()
for ((key, pick) in c.keys.zip(c.picks.map { it.toString() })) {
naive += seen(key, pick, applied.getValue(pick))
val (server, at) = route(token)
routed += seen(key, server, at)
token = maxOf(token, at) // the token rises to the position read
}
println("${c.title}\n naive: ${naive.joinToString(", ")}")
println(" token: ${routed.joinToString(", ")} (token $token)")
}
} In Kotlin: linkedMapOf keeps the insertion order that route relies on, and ?: supplies the leader when firstOrNull finds nothing.
Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
import 'dart:math';
// positions 2 to 5 change other keys
const log = [
(1, 'name', 'Asha'),
(6, 'question', 'Which mode?'),
(7, 'answer', 'Use ANY 1'),
(8, 'name', 'Asha Rao'),
];
const applied = {'A': 7, 'B': 5};
// token, keys read, the followers a plain balancer picks
const cases = [
('Asha reads her name after renaming at 8', 8, ['name'], 'B'),
('Ravi reads the answer, then refreshes', 0, ['answer', 'answer'], 'AB'),
('A page shows an answer and its question', 0, ['answer', 'question'], 'AB'),
];
String seen(String key, String server, int position) {
final values = [
for (final (at, k, value) in log)
if (at <= position && k == key) value,
];
return '$key ${values.isEmpty ? 'nothing' : values.last} from $server';
}
/// The first follower at or past the token serves the read, else the leader.
(String, int) route(int token) {
for (final MapEntry(:key, :value) in applied.entries) {
if (value >= token) return (key, value);
}
return ('leader', log.last.$1);
}
void main() {
for (var (title, token, keys, picks) in cases) {
final naive = <String>[];
final routed = <String>[];
for (var i = 0; i < keys.length; i++) {
naive.add(seen(keys[i], picks[i], applied[picks[i]]!));
final (server, at) = route(token);
routed.add(seen(keys[i], server, at));
token = max(token, at); // the token rises to the position read
}
print('$title\n naive: ${naive.join(', ')}');
print(' token: ${routed.join(', ')} (token $token)');
}
} In Dart: Map literals keep their insertion order, and the MapEntry pattern takes each follower's name and position apart.
Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
Asha reads her name after renaming at 8 naive: name Asha from B token: name Asha Rao from leader (token 8) Ravi reads the answer, then refreshes naive: answer Use ANY 1 from A, answer nothing from B token: answer Use ANY 1 from A, answer Use ANY 1 from A (token 7) A page shows an answer and its question naive: answer Use ANY 1 from A, question nothing from B token: answer Use ANY 1 from A, question Which mode? from A (token 7)
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 12 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 155 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 4 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 8 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 21 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 720 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 193 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 14 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
In all three cases the plain balancer showed old or incomplete data and the token router did not. Asha’s token, 8, was ahead of both followers, so her read went to the leader. Ravi’s case is why the token also rises on reads: he had written nothing, but after seeing follower A’s state at position 7 his token was 7, so a follower behind it, such as B at 5, could no longer serve him. The router relies on two numbers that real databases expose, the position of each commit, which the leader can hand back to the client, and the position each follower has applied.
Checkpoint
Check: The position-token router
Practise: route reads by token
Now write the routing rule yourself; the sample tests include a follower exactly at the token and one that has the write but is unhealthy.
Exercise · Easy · JavaScript
Route a read with the session's position token
A session keeps a position token: the highest log position it has written or read. Reads that only go to followers that have applied the token give the session read-your-writes and monotonic reads. Write two functions in router.mjs.
pickServer(followers, token) gets the followers in order of preference, each as { name, applied, healthy }, where applied is the last log position the follower has applied. Return the name of the first follower that is healthy and has applied at least token. When none qualifies, return 'leader': the leader has every write.
nextToken(token, position) returns the session's token after a read from a server that had applied position. A token only ever moves forward.
pickServer([{ name: 'a', applied: 120, healthy: true }], 120)is'a'pickServer([{ name: 'b', applied: 95, healthy: true }], 100)is'leader'nextToken(120, 95)is120
Starter code · router.mjs
/**
* Where a read goes. `followers` lists { name, applied, healthy } in order of preference; `token` is the highest
* log position the session has written or read. Return the name of the first healthy follower that has applied
* at least `token`, or 'leader' when there is none.
*/
export function pickServer(followers, token) {
// Replace this line with your code.
return 'leader';
}
/** The session's token after it read from a server that had applied `position`. */
export function nextToken(token, position) {
// Replace this line with your code.
return token;
} The sample tests · router.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { pickServer, nextToken } from './router.mjs';
const followers = () => [
{ name: 'a', applied: 120, healthy: true },
{ name: 'b', applied: 95, healthy: true },
{ name: 'c', applied: 130, healthy: false },
];
test('reads from the first follower that has caught up', () => {
assert.equal(pickServer(followers(), 100), 'a');
assert.equal(pickServer(followers(), 90), 'a');
});
test('a follower exactly at the token already has the write', () => {
assert.equal(pickServer(followers(), 120), 'a');
});
test('skips followers that are behind or unhealthy', () => {
const lagging = [{ name: 'b', applied: 95, healthy: true }, { name: 'a', applied: 120, healthy: true }];
assert.equal(pickServer(lagging, 100), 'a');
assert.equal(pickServer(followers(), 125), 'leader');
});
test('falls back to the leader', () => {
assert.equal(pickServer([], 1), 'leader');
assert.equal(pickServer(followers(), 500), 'leader');
});
test('a session that has seen nothing can read from any healthy follower', () => {
assert.equal(pickServer(followers(), 0), 'a');
});
test('the token never goes backwards', () => {
assert.equal(nextToken(100, 120), 120);
assert.equal(nextToken(120, 95), 120);
assert.equal(nextToken(0, 0), 0);
}); A hint
followers.find((f) => …) returns the first follower for which the test is true, or undefined when there is none, and ?? 'leader' turns undefined into the fallback. The test needs two conditions joined with &&. For the token, Math.max keeps the larger of two numbers.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then check yourself on the three anomalies, the four routing rules and the token router, one question at a time.
Practice that adapts to you
Practise this lesson
9 questions about 9 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Each anomaly reproduced, and what each fix costs Pro: opens with a Pro, Premium or Ultimate pass
A seeded model makes all three anomalies happen thousands of times, then measures each fix against the same stalls and the same users. The numbers belong to the model, and changing its lag pattern shows how they move.
In this part:
- Reproducing the three anomalies with a seeded lag model
- What each fix costs, read from the numbers
- Sessions that cross tabs, devices, services and failovers
1 diagram · 1 runnable example (Python) · about 8 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Lag in production: measured, alerted on and routed around Pro: opens with a Pro, Premium or Ultimate pass
Lag can be measured in two units, and each answers a different question. This part shows what PostgreSQL and MySQL report, sizes a read-from-leader window from lag percentiles, and runs a drill in which a replica stops replaying.
In this part:
- Measuring lag in log position and in seconds
- Sizing a read-from-leader window, with numbers
- A failure drill: a replica that stops replaying
- Tokens the database issues for you
1 diagram · 1 runnable example (Python) · about 10 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher to mid · Warm-up
What are read-your-writes, monotonic reads and consistent prefix reads?
They are guarantees about the reads of one session against lagging replicas. Read-your-writes: after you write, your later reads show that write. Monotonic reads: once you have seen some state, your later reads never show an older one. Consistent prefix reads: you see writes in the order they were made, so never an answer without its question. All three can be had without making every replica current, for example by reading only from replicas that have applied the position of your latest write or read.
-
Mid to senior
Users report that after editing their profile, the page shows the old name for a few seconds, and sometimes the new name appears and then disappears on refresh. Reads go to three replicas behind a load balancer. Diagnose it, and fix it without sending every read to the primary.
-
Senior to staff
Your team sends a user's reads to the leader for one second after each write. Complaints about missing edits fall, but complaints that content jumps back to an older state rise. Explain why, and design a better routing rule.
-
Senior to staff
Your system uses log-position tokens for read-your-writes. After a failover, a few users find that writes they made a minute earlier are gone, although every follower they read from had passed their token. What happened, and how do you change the design?
Key takeaways
- Lag is normal; the anomalies come from where one session’s reads land: on a follower before its own write, or on followers at different positions.
- Read-your-writes, monotonic reads and consistent prefix reads are session guarantees, far cheaper than making every follower current.
- A leader window and a sticky follower each give only part of the three; a position token gives all three by construction.
- The token router reads from the first follower that has applied the session’s token, else from the leader, and raises the token to the position it read.
Terms in this lesson
- consistent prefix reads
- A session guarantee: a session sees writes in the order they were made, so it never sees an answer without the question it answers.
- monotonic reads
- A session guarantee: once a session has seen some state of the data, its later reads never show an older one, so a refresh never goes back in time.
- read-your-writes
- A session guarantee: after a session writes, its later reads show that write, even when they are served by followers that lag behind the leader.
- session guarantee
- A promise about the reads of one session against lagging replicas, such as read-your-writes, rather than about every client at once. It costs far less than keeping every replica current.
References
- Replicated Data Consistency Explained Through Baseball (MSR technical report) (Doug Terry, Microsoft Research)
- Consistency models: read your writes (Jepsen)
- Consistency models: monotonic reads (Jepsen)
- PostgreSQL documentation: Log-Shipping Standby Servers (monitoring) (The PostgreSQL Global Development Group)
- PostgreSQL documentation: System Administration Functions (WAL positions) (The PostgreSQL Global Development Group)
- PostgreSQL documentation: The Cumulative Statistics System (pg_stat_replication) (The PostgreSQL Global Development Group)
- PostgreSQL documentation: Hot Standby (query conflicts) (The PostgreSQL Global Development Group)
- PostgreSQL documentation: Replication settings (max_standby_streaming_delay) (The PostgreSQL Global Development Group)
- MySQL 8.4 Reference Manual: Functions Used with Global Transaction Identifiers (Oracle Corporation)
- MySQL 8.4 Reference Manual: Server Tracking of Client Session State (Oracle Corporation)
- MySQL 8.4 Reference Manual: SHOW REPLICA STATUS (Oracle Corporation)
- Causal Consistency and Read and Write Concerns (MongoDB)
- Read Isolation, Consistency, and Recency: client sessions and causal consistency (MongoDB)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress